@aligndottech/cli 0.1.3 → 0.2.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.
Files changed (122) hide show
  1. package/README.md +151 -54
  2. package/dist/commands/check.d.ts.map +1 -1
  3. package/dist/commands/check.js +108 -0
  4. package/dist/commands/check.js.map +1 -1
  5. package/dist/commands/links.js +1 -1
  6. package/dist/commands/links.js.map +1 -1
  7. package/dist/commands/local.d.ts +3 -0
  8. package/dist/commands/local.d.ts.map +1 -0
  9. package/dist/commands/local.js +71 -0
  10. package/dist/commands/local.js.map +1 -0
  11. package/dist/commands/login.d.ts.map +1 -1
  12. package/dist/commands/login.js +4 -54
  13. package/dist/commands/login.js.map +1 -1
  14. package/dist/commands/mcp.d.ts +2 -0
  15. package/dist/commands/mcp.d.ts.map +1 -1
  16. package/dist/commands/mcp.js +32 -11
  17. package/dist/commands/mcp.js.map +1 -1
  18. package/dist/commands/setup.d.ts.map +1 -1
  19. package/dist/commands/setup.js +583 -199
  20. package/dist/commands/setup.js.map +1 -1
  21. package/dist/commands/why.d.ts.map +1 -1
  22. package/dist/commands/why.js +46 -4
  23. package/dist/commands/why.js.map +1 -1
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/lib/advisory-dedup.d.ts +3 -0
  27. package/dist/lib/advisory-dedup.d.ts.map +1 -0
  28. package/dist/lib/advisory-dedup.js +44 -0
  29. package/dist/lib/advisory-dedup.js.map +1 -0
  30. package/dist/lib/agent-rules.d.ts +10 -0
  31. package/dist/lib/agent-rules.d.ts.map +1 -0
  32. package/dist/lib/agent-rules.js +137 -0
  33. package/dist/lib/agent-rules.js.map +1 -0
  34. package/dist/lib/config.d.ts +4 -1
  35. package/dist/lib/config.d.ts.map +1 -1
  36. package/dist/lib/config.js +11 -0
  37. package/dist/lib/config.js.map +1 -1
  38. package/dist/lib/fetchers/confluence.d.ts +2 -0
  39. package/dist/lib/fetchers/confluence.d.ts.map +1 -1
  40. package/dist/lib/fetchers/confluence.js +10 -32
  41. package/dist/lib/fetchers/confluence.js.map +1 -1
  42. package/dist/lib/fetchers/git.d.ts +2 -0
  43. package/dist/lib/fetchers/git.d.ts.map +1 -1
  44. package/dist/lib/fetchers/git.js +5 -12
  45. package/dist/lib/fetchers/git.js.map +1 -1
  46. package/dist/lib/fetchers/github.d.ts +1 -0
  47. package/dist/lib/fetchers/github.d.ts.map +1 -1
  48. package/dist/lib/fetchers/github.js +3 -40
  49. package/dist/lib/fetchers/github.js.map +1 -1
  50. package/dist/lib/fetchers/gitlab.d.ts +1 -0
  51. package/dist/lib/fetchers/gitlab.d.ts.map +1 -1
  52. package/dist/lib/fetchers/gitlab.js +3 -21
  53. package/dist/lib/fetchers/gitlab.js.map +1 -1
  54. package/dist/lib/fetchers/jira.d.ts +2 -0
  55. package/dist/lib/fetchers/jira.d.ts.map +1 -1
  56. package/dist/lib/fetchers/jira.js +10 -51
  57. package/dist/lib/fetchers/jira.js.map +1 -1
  58. package/dist/lib/fetchers/linear.d.ts +1 -0
  59. package/dist/lib/fetchers/linear.d.ts.map +1 -1
  60. package/dist/lib/fetchers/linear.js +3 -55
  61. package/dist/lib/fetchers/linear.js.map +1 -1
  62. package/dist/lib/fetchers/notion.d.ts +1 -0
  63. package/dist/lib/fetchers/notion.d.ts.map +1 -1
  64. package/dist/lib/fetchers/notion.js +3 -44
  65. package/dist/lib/fetchers/notion.js.map +1 -1
  66. package/dist/lib/fetchers/slack.d.ts +1 -0
  67. package/dist/lib/fetchers/slack.d.ts.map +1 -1
  68. package/dist/lib/fetchers/slack.js +3 -59
  69. package/dist/lib/fetchers/slack.js.map +1 -1
  70. package/dist/lib/fetchers/teams.d.ts +1 -0
  71. package/dist/lib/fetchers/teams.d.ts.map +1 -1
  72. package/dist/lib/fetchers/teams.js +3 -59
  73. package/dist/lib/fetchers/teams.js.map +1 -1
  74. package/dist/lib/fetchers/zoom.d.ts +1 -0
  75. package/dist/lib/fetchers/zoom.d.ts.map +1 -1
  76. package/dist/lib/fetchers/zoom.js +3 -57
  77. package/dist/lib/fetchers/zoom.js.map +1 -1
  78. package/dist/lib/format-date.d.ts +7 -0
  79. package/dist/lib/format-date.d.ts.map +1 -0
  80. package/dist/lib/format-date.js +26 -0
  81. package/dist/lib/format-date.js.map +1 -0
  82. package/dist/lib/gateway-client.d.ts +9 -8
  83. package/dist/lib/gateway-client.d.ts.map +1 -1
  84. package/dist/lib/gateway-client.js +28 -3
  85. package/dist/lib/gateway-client.js.map +1 -1
  86. package/dist/lib/hook-payload.d.ts +20 -0
  87. package/dist/lib/hook-payload.d.ts.map +1 -0
  88. package/dist/lib/hook-payload.js +30 -0
  89. package/dist/lib/hook-payload.js.map +1 -0
  90. package/dist/lib/local-db.d.ts +51 -0
  91. package/dist/lib/local-db.d.ts.map +1 -0
  92. package/dist/lib/local-db.js +89 -0
  93. package/dist/lib/local-db.js.map +1 -0
  94. package/dist/lib/local-embeddings.d.ts +3 -0
  95. package/dist/lib/local-embeddings.d.ts.map +1 -0
  96. package/dist/lib/local-embeddings.js +20 -0
  97. package/dist/lib/local-embeddings.js.map +1 -0
  98. package/dist/lib/local-gateway-client.d.ts +98 -0
  99. package/dist/lib/local-gateway-client.d.ts.map +1 -0
  100. package/dist/lib/local-gateway-client.js +151 -0
  101. package/dist/lib/local-gateway-client.js.map +1 -0
  102. package/dist/lib/local-mode.d.ts +7 -0
  103. package/dist/lib/local-mode.d.ts.map +1 -0
  104. package/dist/lib/local-mode.js +39 -0
  105. package/dist/lib/local-mode.js.map +1 -0
  106. package/dist/lib/local-relationship-classifier.d.ts +14 -0
  107. package/dist/lib/local-relationship-classifier.d.ts.map +1 -0
  108. package/dist/lib/local-relationship-classifier.js +100 -0
  109. package/dist/lib/local-relationship-classifier.js.map +1 -0
  110. package/dist/lib/login-flow.d.ts +5 -0
  111. package/dist/lib/login-flow.d.ts.map +1 -0
  112. package/dist/lib/login-flow.js +64 -0
  113. package/dist/lib/login-flow.js.map +1 -0
  114. package/dist/lib/personal-import.d.ts +4 -0
  115. package/dist/lib/personal-import.d.ts.map +1 -1
  116. package/dist/lib/personal-import.js +133 -27
  117. package/dist/lib/personal-import.js.map +1 -1
  118. package/package.json +27 -15
  119. package/dist/lib/why-normalise.d.ts +0 -2
  120. package/dist/lib/why-normalise.d.ts.map +0 -1
  121. package/dist/lib/why-normalise.js +0 -39
  122. package/dist/lib/why-normalise.js.map +0 -1
@@ -2,14 +2,22 @@ import { resolveEnv } from '../lib/resolve-env.js';
2
2
  import * as p from '@clack/prompts';
3
3
  import chalk from 'chalk';
4
4
  import open from 'open';
5
+ import { execa } from 'execa';
5
6
  import { createConfigStore } from '../lib/config.js';
6
7
  import { createGatewayClient } from '../lib/gateway-client.js';
7
- import { runPersonalImport } from '../lib/personal-import.js';
8
+ import { runPersonalImport, runWithConcurrency } from '../lib/personal-import.js';
8
9
  import { detectEditors, writeMcpConfig } from '../lib/mcp-setup.js';
10
+ import { setupAgentAlignment } from '../lib/agent-rules.js';
9
11
  import { isGitRepo } from '../lib/git.js';
12
+ import { initLocalMode } from '../lib/local-mode.js';
13
+ import { loginInteractive } from '../lib/login-flow.js';
10
14
  import { resolveAppUrl } from '../lib/env-resolver.js';
11
15
  import { CLI_CALLBACK_PORTS, waitForCallback } from '../lib/cli-oauth.js';
12
16
  import { AuthExpiredError } from '../lib/errors.js';
17
+ const TIER_ORDER = { personal: 0, site: 1, workspace: 2 };
18
+ // How many connectors import concurrently after auth. Each import is itself
19
+ // batch-parallel (runPersonalImport), so this bounds total gateway load.
20
+ const IMPORT_CONCURRENCY = 4;
13
21
  function buildSources(gitAvailable) {
14
22
  const sources = [];
15
23
  if (gitAvailable) {
@@ -19,7 +27,7 @@ function buildSources(gitAvailable) {
19
27
  description: 'Commit history from this repo - no token needed',
20
28
  fetch: async () => {
21
29
  const { fetchGitItems } = await import('../lib/fetchers/git.js');
22
- return fetchGitItems({ limit: 100 });
30
+ return fetchGitItems({ limit: 500 });
23
31
  },
24
32
  });
25
33
  }
@@ -27,7 +35,12 @@ function buildSources(gitAvailable) {
27
35
  id: 'github',
28
36
  label: 'GitHub',
29
37
  description: 'Your PRs and issues',
30
- oauthKey: 'github',
38
+ tier: 'personal',
39
+ oauthKey: 'github-personal',
40
+ // Token-paste metadata is used only by local mode (cloud uses oauthKey/OAuth).
41
+ tokenLabel: 'Personal access token',
42
+ tokenHint: 'Use a fine-grained token, read-only: Contents, Issues, Pull requests = Read',
43
+ tokenUrl: 'https://github.com/settings/personal-access-tokens/new',
31
44
  fetch: async (t) => {
32
45
  const { fetchGitHubItems } = await import('../lib/fetchers/github.js');
33
46
  return fetchGitHubItems({ token: t['token'], limit: 100 });
@@ -36,7 +49,17 @@ function buildSources(gitAvailable) {
36
49
  id: 'jira',
37
50
  label: 'Jira',
38
51
  description: 'Your issues',
39
- oauthKey: 'jira',
52
+ tier: 'site',
53
+ // Personal/CLI tier is read-only (no write:jira-work). The team/org
54
+ // comment bot keeps write via the `jira` key. See ALI-94.
55
+ oauthKey: 'jira-personal',
56
+ // Local-mode token paste (read-only Atlassian API token + email + site).
57
+ tokenLabel: 'API token',
58
+ tokenUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
59
+ extraFields: [
60
+ { key: 'email', label: 'Atlassian account email' },
61
+ { key: 'domain', label: 'Atlassian domain (yourorg.atlassian.net)' },
62
+ ],
40
63
  fetch: async (t) => {
41
64
  const { fetchJiraItems } = await import('../lib/fetchers/jira.js');
42
65
  return fetchJiraItems({ token: t['token'], cloudId: t['cloudId'], email: t['email'], domain: t['domain'], limit: 100 });
@@ -45,7 +68,16 @@ function buildSources(gitAvailable) {
45
68
  id: 'confluence',
46
69
  label: 'Confluence',
47
70
  description: 'Your pages and documentation',
48
- oauthKey: 'confluence',
71
+ tier: 'site',
72
+ // Read-only personal/CLI tier. See ALI-94.
73
+ oauthKey: 'confluence-personal',
74
+ // Local-mode token paste (read-only Atlassian API token + email + site).
75
+ tokenLabel: 'API token',
76
+ tokenUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
77
+ extraFields: [
78
+ { key: 'email', label: 'Atlassian account email' },
79
+ { key: 'domain', label: 'Atlassian domain (yourorg.atlassian.net)' },
80
+ ],
49
81
  fetch: async (t) => {
50
82
  const { fetchConfluenceItems } = await import('../lib/fetchers/confluence.js');
51
83
  return fetchConfluenceItems({ token: t['token'], cloudId: t['cloudId'], email: t['email'], domain: t['domain'], limit: 50 });
@@ -53,8 +85,15 @@ function buildSources(gitAvailable) {
53
85
  }, {
54
86
  id: 'slack',
55
87
  label: 'Slack',
56
- description: 'Decision threads from your channels [experimental]',
57
- oauthKey: 'slack',
88
+ description: 'Decision threads from your channels - may need workspace admin [experimental]',
89
+ tier: 'workspace',
90
+ // Read-only personal/CLI tier (no chat:write). The team/org bot keeps
91
+ // chat:write via the `slack` key. See ALI-94.
92
+ oauthKey: 'slack-personal',
93
+ // Local-mode token paste: a Slack user token (xoxp-) with read scopes only.
94
+ tokenLabel: 'User token (xoxp-...)',
95
+ tokenHint: 'User token with read scopes only: channels:read, channels:history, groups:read, groups:history',
96
+ tokenUrl: 'https://api.slack.com/apps',
58
97
  fetch: async (t) => {
59
98
  const { fetchSlackItems } = await import('../lib/fetchers/slack.js');
60
99
  return fetchSlackItems({ token: t['token'], limit: 50, daysBack: 90 });
@@ -62,7 +101,8 @@ function buildSources(gitAvailable) {
62
101
  }, {
63
102
  id: 'teams',
64
103
  label: 'Microsoft Teams',
65
- description: 'Channel messages and decisions from your teams',
104
+ description: 'Channel messages and decisions - may need org/workspace admin consent',
105
+ tier: 'workspace',
66
106
  oauthKey: 'teams',
67
107
  fetch: async (t) => {
68
108
  const { fetchTeamsItems } = await import('../lib/fetchers/teams.js');
@@ -72,6 +112,7 @@ function buildSources(gitAvailable) {
72
112
  id: 'zoom',
73
113
  label: 'Zoom',
74
114
  description: 'Cloud recording transcripts from your meetings',
115
+ tier: 'personal',
75
116
  oauthKey: 'zoom',
76
117
  fetch: async (t) => {
77
118
  const { fetchZoomItems } = await import('../lib/fetchers/zoom.js');
@@ -81,8 +122,20 @@ function buildSources(gitAvailable) {
81
122
  id: 'gitlab',
82
123
  label: 'GitLab',
83
124
  description: 'Your merge requests',
125
+ tier: 'personal',
126
+ // gitlab.com → read-only browser OAuth (scope read_api, ALI-102). A
127
+ // self-managed host (custom domain) can't use the fixed gitlab.com OAuth
128
+ // app, so it falls back to the read-only PAT path below.
129
+ oauthKey: 'gitlab-personal',
130
+ hostGatedOAuth: { field: 'domain' },
84
131
  tokenLabel: 'Personal access token',
85
- tokenHint: 'gitlab.com/-/user_settings/personal_access_tokens - tick "read_api"',
132
+ // Read-only tier: steer users to the read-only scope. `api` would grant
133
+ // write; `read_api` is read-only and all Align's import needs. See ALI-98.
134
+ tokenHint: 'Select ONLY "read_api" (not "api") so the token stays read-only',
135
+ tokenUrl: (t) => {
136
+ const base = t['domain'] ? `https://${t['domain']}` : 'https://gitlab.com';
137
+ return `${base}/-/user_settings/personal_access_tokens`;
138
+ },
86
139
  extraFields: [
87
140
  { key: 'domain', label: 'GitLab domain (leave blank for gitlab.com)' },
88
141
  ],
@@ -94,8 +147,13 @@ function buildSources(gitAvailable) {
94
147
  id: 'linear',
95
148
  label: 'Linear',
96
149
  description: 'Your issues and project discussions',
150
+ tier: 'personal',
151
+ // Read-only personal/CLI tier via browser OAuth (scope `read`), replacing the
152
+ // full-access API-key paste. Requires the Linear OAuth app + sealed creds. See ALI-101.
153
+ oauthKey: 'linear-personal',
154
+ // Local-mode token paste: a Linear personal API key (read-only graph).
97
155
  tokenLabel: 'Personal API key (lin_api_...)',
98
- tokenHint: 'linear.app/settings/api - Personal API keys',
156
+ tokenUrl: 'https://linear.app/settings/api',
99
157
  fetch: async (t) => {
100
158
  const { fetchLinearItems } = await import('../lib/fetchers/linear.js');
101
159
  return fetchLinearItems({ token: t['token'], limit: 100 });
@@ -104,8 +162,18 @@ function buildSources(gitAvailable) {
104
162
  id: 'notion',
105
163
  label: 'Notion',
106
164
  description: 'Your pages and databases',
165
+ tier: 'personal',
166
+ // Read-only personal/CLI tier via browser OAuth (public integration),
167
+ // replacing the internal-integration-secret paste in cloud. Read-only is
168
+ // governed by the integration's capabilities (Read content), not scopes.
169
+ // Requires the Notion OAuth app + sealed creds. See ALI-104.
170
+ oauthKey: 'notion-personal',
171
+ // Local-mode token paste: a read-only internal integration secret.
107
172
  tokenLabel: 'Integration secret (secret_...)',
108
- tokenHint: 'notion.so/my-integrations - New integration - show Internal Integration Secret',
173
+ // Read-only tier: Align only reads. Notion integration capabilities are set
174
+ // at creation - keep it to "Read content" (no insert/update). See ALI-98.
175
+ tokenHint: 'Create an integration with ONLY "Read content" capability (no insert/update), then copy its Internal Integration Secret',
176
+ tokenUrl: 'https://www.notion.so/my-integrations',
109
177
  fetch: async (t) => {
110
178
  const { fetchNotionItems } = await import('../lib/fetchers/notion.js');
111
179
  return fetchNotionItems({ token: t['token'], limit: 50 });
@@ -116,19 +184,27 @@ function buildSources(gitAvailable) {
116
184
  // ---------------------------------------------------------------------------
117
185
  // Token collection helper
118
186
  // ---------------------------------------------------------------------------
119
- async function collectTokens(source) {
120
- const tokens = {};
187
+ async function collectTokens(source, seed = {}) {
188
+ // `seed` pre-populates already-known fields (e.g. a self-managed host gathered
189
+ // up front) so tokenUrl() resolves against the right host.
190
+ const tokens = { ...seed };
121
191
  // Extra fields first (email, domain for Jira/Confluence)
122
192
  for (const field of source.extraFields ?? []) {
123
- const val = await p.text({ message: ` ${field.label}:` });
193
+ // defaultValue '' so a blank submit renders empty, not the literal "undefined".
194
+ const val = await p.text({ message: ` ${field.label}:`, defaultValue: '' });
124
195
  if (p.isCancel(val))
125
196
  return null;
126
- tokens[field.key] = val;
197
+ tokens[field.key] = (val ?? '');
127
198
  }
128
199
  // Main token
129
200
  if (source.tokenLabel) {
201
+ if (source.tokenUrl) {
202
+ const url = typeof source.tokenUrl === 'function' ? source.tokenUrl(tokens) : source.tokenUrl;
203
+ p.log.info(chalk.dim(` Opening ${source.label} in browser...`));
204
+ await open(url).catch(() => { });
205
+ }
130
206
  if (source.tokenHint) {
131
- p.log.info(chalk.dim(` Get your token: ${source.tokenHint}`));
207
+ p.log.info(chalk.dim(` ${source.tokenHint}`));
132
208
  }
133
209
  const token = await p.password({ message: ` ${source.tokenLabel}:` });
134
210
  if (p.isCancel(token))
@@ -140,23 +216,57 @@ async function collectTokens(source) {
140
216
  // ---------------------------------------------------------------------------
141
217
  // OAuth browser flow helper for connectors
142
218
  // ---------------------------------------------------------------------------
143
- async function collectTokensViaOAuth(source, client, config, envName, reset = false) {
219
+ // Jira and Confluence share a single Atlassian OAuth consent, so one browser
220
+ // sign-in connects both. Present the flow as "Atlassian (Jira & Confluence)" so a
221
+ // user connecting "Jira" isn't surprised that Confluence is connected too.
222
+ const ATLASSIAN_OAUTH_KEYS = new Set(['jira-personal', 'confluence-personal']);
223
+ function isAtlassianOAuth(source) {
224
+ return !!source.oauthKey && ATLASSIAN_OAUTH_KEYS.has(source.oauthKey);
225
+ }
226
+ function oauthFlowLabel(source) {
227
+ return isAtlassianOAuth(source) ? 'Atlassian (Jira & Confluence)' : source.label;
228
+ }
229
+ function connectorDisplayName(connectorKey) {
230
+ if (connectorKey.startsWith('confluence'))
231
+ return 'Confluence';
232
+ if (connectorKey.startsWith('jira'))
233
+ return 'Jira';
234
+ return connectorKey;
235
+ }
236
+ async function collectTokensViaOAuth(source, client, config, envName, reset = false, connectedThisRun) {
144
237
  const key = source.oauthKey;
145
- if (!reset) {
238
+ const readCachedTokens = () => {
146
239
  const cached = config.getConnectorToken(envName, key);
240
+ if (!cached)
241
+ return null;
242
+ const cachedCloudId = config.getConnectorCloudId(envName, key);
243
+ const cachedSiteBase = config.getConnectorSiteBase(envName, key);
244
+ return {
245
+ token: cached,
246
+ ...(cachedCloudId ? { cloudId: cachedCloudId } : {}),
247
+ ...(cachedSiteBase ? { siteBase: cachedSiteBase } : {}),
248
+ };
249
+ };
250
+ // Connected earlier in THIS run (the Atlassian sibling: Jira and Confluence
251
+ // share one OAuth app + token, so one consent connects both). Reuse it even
252
+ // under --reset, which is meant to ignore STALE tokens from prior runs, not
253
+ // ones just obtained moments ago this run.
254
+ if (connectedThisRun?.has(key)) {
255
+ const reused = readCachedTokens();
256
+ if (reused) {
257
+ p.log.info(chalk.dim(` ${source.label}: already connected via a shared sign-in this run`));
258
+ return reused;
259
+ }
260
+ }
261
+ if (!reset) {
262
+ const cached = readCachedTokens();
147
263
  if (cached) {
148
264
  p.log.info(chalk.dim(` ${source.label}: using cached OAuth token (run align setup --reset to re-auth)`));
149
- const cachedCloudId = config.getConnectorCloudId(envName, key);
150
- const cachedSiteBase = config.getConnectorSiteBase(envName, key);
151
- return {
152
- token: cached,
153
- ...(cachedCloudId ? { cloudId: cachedCloudId } : {}),
154
- ...(cachedSiteBase ? { siteBase: cachedSiteBase } : {}),
155
- };
265
+ return cached;
156
266
  }
157
267
  }
158
268
  const spinner = p.spinner();
159
- spinner.start(`Opening browser for ${source.label} OAuth...`);
269
+ spinner.start(`Opening browser for ${oauthFlowLabel(source)} OAuth...`);
160
270
  let authUrl = '';
161
271
  const callbackPromise = waitForCallback({
162
272
  ports: CLI_CALLBACK_PORTS,
@@ -166,7 +276,7 @@ async function collectTokensViaOAuth(source, client, config, envName, reset = fa
166
276
  const result = await client.startCliOAuth(key, port, nonce);
167
277
  authUrl = result.authUrl;
168
278
  await open(authUrl).catch(() => { });
169
- spinner.stop(`Browser opened for ${source.label}. If nothing happened, visit:\n ${chalk.bold(authUrl)}`);
279
+ spinner.stop(`Browser opened for ${oauthFlowLabel(source)}. If nothing happened, visit:\n ${chalk.bold(authUrl)}`);
170
280
  p.log.info('Waiting for you to approve in the browser (2 min timeout)...');
171
281
  }
172
282
  catch (e) {
@@ -188,25 +298,150 @@ async function collectTokensViaOAuth(source, client, config, envName, reset = fa
188
298
  p.log.warn(`${source.label} OAuth did not return an access token.`);
189
299
  return null;
190
300
  }
191
- config.setConnectorToken(envName, key, accessToken);
192
- // Persist Atlassian cloudId and human site base so future runs (and align import) can use OAuth
301
+ // accessToken being truthy guarantees credentials is defined
302
+ persistConnectorCreds(config, envName, key, credentials);
303
+ connectedThisRun?.add(key);
304
+ // Atlassian: Jira and Confluence share one OAuth app, so a single consent
305
+ // returns the sibling's credentials too. Persist them AND mark the sibling
306
+ // connected this run so its own iteration reuses the token and skips a second
307
+ // browser flow (even under --reset).
308
+ const siblingConnector = result.data['siblingConnector'];
309
+ const siblingCreds = result.data['siblingCredentials'];
310
+ if (siblingConnector && siblingCreds?.['access_token']) {
311
+ persistConnectorCreds(config, envName, siblingConnector, siblingCreds);
312
+ connectedThisRun?.add(siblingConnector);
313
+ p.log.info(chalk.dim(` Also connected ${connectorDisplayName(siblingConnector)} (shared Atlassian app - no second sign-in needed)`));
314
+ }
193
315
  const cloudId = credentials?.['site_id'];
316
+ const siteBase = credentials?.['base'];
317
+ return { token: accessToken, ...(cloudId ? { cloudId } : {}), ...(siteBase ? { siteBase } : {}) };
318
+ }
319
+ // Persist a connector's OAuth token plus Atlassian cloudId/site base so future
320
+ // runs (and `align import`) can reuse the credentials without re-auth.
321
+ function persistConnectorCreds(config, envName, key, credentials) {
322
+ const accessToken = credentials['access_token'];
323
+ if (!accessToken)
324
+ return;
325
+ config.setConnectorToken(envName, key, accessToken);
326
+ const cloudId = credentials['site_id'];
194
327
  if (cloudId)
195
328
  config.setConnectorCloudId(envName, key, cloudId);
196
- const siteBase = credentials?.['base'];
329
+ const siteBase = credentials['base'];
197
330
  if (siteBase)
198
331
  config.setConnectorSiteBase(envName, key, siteBase);
199
- return { token: accessToken, ...(cloudId ? { cloudId } : {}), ...(siteBase ? { siteBase } : {}) };
332
+ }
333
+ // ---------------------------------------------------------------------------
334
+ // Deterministic auto-alignment (ALI-121)
335
+ // ---------------------------------------------------------------------------
336
+ // Write the project-local, committed agent-rules files (Claude Code PostToolUse hook +
337
+ // CLAUDE.md nudge + Cursor rule) so alignment context fires regardless of model
338
+ // discretion. Best-effort: a write failure (read-only dir, weird CWD) must never abort
339
+ // onboarding, so we warn and continue.
340
+ function writeAgentAlignment(envName) {
341
+ try {
342
+ const written = setupAgentAlignment({ cwd: process.cwd(), env: envName });
343
+ p.log.success(`Auto-alignment configured: ${written.join(', ')}`);
344
+ p.log.info(chalk.dim(' A PostToolUse hook will check edits against your decision graph. Claude Code asks ' +
345
+ 'once to approve project hooks - accept it to enable automatic alignment.'));
346
+ }
347
+ catch (err) {
348
+ p.log.warn(`Could not write auto-alignment files: ${err.message}`);
349
+ }
200
350
  }
201
351
  // ---------------------------------------------------------------------------
202
352
  // Command registration
203
353
  // ---------------------------------------------------------------------------
354
+ // Local-embedded onboarding (opt-in via --local): no account, no cloud, no OAuth.
355
+ // Initializes the local graph, wires editor MCP configs to --env local, and
356
+ // seeds the graph from git history - all on the user's machine. This is the
357
+ // privacy/offline escape hatch; the default solo experience is a personal
358
+ // cloud tenant (see the cloud path below).
359
+ async function runLocalSetup() {
360
+ const { dbPath } = await initLocalMode({ quiet: false });
361
+ p.log.success('Local graph ready - no account needed, your data stays on this machine.');
362
+ // Deterministic auto-alignment files target the local graph (advisory check runs --env local).
363
+ writeAgentAlignment('local');
364
+ const config = createConfigStore();
365
+ const localEnv = config.getEnvironment('local');
366
+ const localClient = createGatewayClient(localEnv);
367
+ if (await isGitRepo()) {
368
+ console.log('');
369
+ p.log.info(chalk.dim('First import downloads a small local embedding model (~90MB), one time.'));
370
+ const gitSpinner = p.spinner();
371
+ gitSpinner.start('Scanning git history...');
372
+ try {
373
+ const gitSource = buildSources(true).find(s => s.id === 'git');
374
+ const items = await gitSource.fetch({});
375
+ if (items.length) {
376
+ gitSpinner.stop(`Found ${items.length} commits worth importing`);
377
+ await runPersonalImport(items, localClient, {
378
+ label: 'Git',
379
+ approve: true,
380
+ appUrl: resolveAppUrl(localEnv),
381
+ local: true,
382
+ });
383
+ }
384
+ else {
385
+ gitSpinner.stop('No decisions found in git history');
386
+ }
387
+ }
388
+ catch {
389
+ gitSpinner.stop('Git import skipped');
390
+ }
391
+ }
392
+ // Connectors: OAuth can't run offline (needs the hosted callback), so local mode
393
+ // connects via manual read-only token paste. Only sources with a tokenLabel are
394
+ // pasteable (Teams/Zoom have no personal token → excluded). See ALI-103.
395
+ const localConnectors = buildSources(false)
396
+ .filter((s) => s.id !== 'git' && s.tokenLabel)
397
+ .sort((a, b) => TIER_ORDER[a.tier ?? 'personal'] - TIER_ORDER[b.tier ?? 'personal']);
398
+ console.log('');
399
+ const selected = await p.multiselect({
400
+ message: 'Connect more sources with a read-only token? (skip to finish)',
401
+ options: localConnectors.map((s) => ({ value: s.id, label: s.label, hint: s.description })),
402
+ required: false,
403
+ });
404
+ if (!p.isCancel(selected)) {
405
+ for (const id of selected) {
406
+ const source = localConnectors.find((s) => s.id === id);
407
+ if (!source)
408
+ continue;
409
+ console.log('');
410
+ p.log.step(chalk.bold(source.label));
411
+ const tokens = await collectTokens(source);
412
+ if (!tokens)
413
+ continue;
414
+ const spinner = p.spinner();
415
+ spinner.start(`Fetching from ${source.label}...`);
416
+ try {
417
+ const items = await source.fetch(tokens);
418
+ spinner.stop(`Found ${items.length} items`);
419
+ if (items.length) {
420
+ await runPersonalImport(items, localClient, {
421
+ label: source.label,
422
+ approve: true,
423
+ appUrl: resolveAppUrl(localEnv),
424
+ local: true,
425
+ });
426
+ }
427
+ }
428
+ catch (e) {
429
+ spinner.stop(`Skipped ${source.label} - ${e.message}`);
430
+ }
431
+ }
432
+ }
433
+ p.outro(`${chalk.green('You are set up in local mode.')}\n` +
434
+ ` Graph: ${chalk.dim(dbPath)}\n` +
435
+ ` Ask your agent: ${chalk.bold('"What decisions exist in this codebase?"')}\n` +
436
+ ` ${chalk.dim('align local status')} shows stats; ${chalk.dim('align local reset')} wipes it.`);
437
+ }
204
438
  export function registerSetupCommand(program) {
205
439
  program
206
440
  .command('setup')
207
441
  .description('Guided onboarding: connect your tools and configure MCP in one command')
208
442
  .option('--env <env>', 'Environment')
209
443
  .option('--approve', 'Skip confirmation prompts (for scripted use)')
444
+ .option('--local', 'Set up local-only mode (no account, no cloud)')
210
445
  .option('--reset', 'Clear cached OAuth tokens and re-authenticate all connectors')
211
446
  .action(async (opts) => {
212
447
  const config = createConfigStore();
@@ -214,205 +449,354 @@ export function registerSetupCommand(program) {
214
449
  const env = config.getEnvironment(envName);
215
450
  const client = createGatewayClient(env);
216
451
  p.intro(chalk.bgMagenta.white(' align setup '));
217
- p.log.info('Building your decision graph from the tools your team already uses.');
218
- p.log.info('Takes about 10 minutes. Ctrl+C to cancel at any step.');
219
- console.log('');
220
- // ---- Step 1: Auth check ----
221
- const authSpinner = p.spinner();
222
- authSpinner.start('Checking authentication...');
223
- try {
224
- const me = await client.whoami();
225
- authSpinner.stop(`Logged in as ${me.user.email} (${me.tenant?.name ?? envName})`);
452
+ // ---- Step 0: Cloud (default) vs local (--local) ----
453
+ // Solo defaults to a personal CLOUD tenant: telemetry, the real cloud
454
+ // relationship classifier, backup, and a clean upgrade path to a team
455
+ // (reuses the personal->org join flow). --local is the opt-in offline
456
+ // escape hatch; --approve runs the cloud path non-interactively.
457
+ let mode;
458
+ if (opts.local) {
459
+ mode = 'local';
226
460
  }
227
- catch {
228
- authSpinner.stop('Not authenticated');
461
+ else if (opts.approve) {
462
+ mode = 'cloud';
463
+ }
464
+ else {
465
+ const choice = await p.select({
466
+ message: 'How are you using Align?',
467
+ options: [
468
+ { value: 'cloud', label: 'Cloud (recommended) - your personal decision graph', hint: 'syncs, backed up, upgradeable to a team' },
469
+ { value: 'local', label: 'Local only - private, offline, no account', hint: 'stays on this machine (--local)' },
470
+ ],
471
+ initialValue: 'cloud',
472
+ });
473
+ if (p.isCancel(choice)) {
474
+ p.cancel('Cancelled.');
475
+ process.exit(0);
476
+ }
477
+ mode = choice;
478
+ }
479
+ if (mode === 'local') {
480
+ await runLocalSetup();
481
+ return;
482
+ }
483
+ await runCloudSetup({ opts, config, env, client, envName });
484
+ });
485
+ }
486
+ // Cloud (personal-tenant) onboarding: verify login, wire MCP, seed from git,
487
+ // then offer personal-scoped connectors. A personal-email login lands on an
488
+ // isolated personal tenant server-side; connectors auto-bind to it.
489
+ async function runCloudSetup(ctx) {
490
+ const { opts, config, env, envName } = ctx;
491
+ let client = ctx.client;
492
+ // ---- Step 1: Auth check (inline login when interactive + unauthenticated) ----
493
+ const authSpinner = p.spinner();
494
+ authSpinner.start('Checking authentication...');
495
+ try {
496
+ const me = await client.whoami();
497
+ authSpinner.stop(`Logged in as ${me.user.email} (${me.tenant?.name ?? envName})`);
498
+ }
499
+ catch {
500
+ authSpinner.stop('Not authenticated');
501
+ // Scripted runs (--approve) must not block on a browser; fail fast.
502
+ if (opts.approve) {
229
503
  p.log.warn(`Run ${chalk.bold('align login')} first, then re-run ${chalk.bold('align setup')}.`);
230
504
  process.exit(1);
231
505
  }
232
- // Snapshot pre-import link count so we can report the delta after imports
233
- let preImportLinkCount = 0;
506
+ const wantLogin = await p.confirm({ message: 'Log in to Align now? (your personal cloud graph)' });
507
+ if (!p.isCancel(wantLogin) && wantLogin) {
508
+ const ok = await loginInteractive(env, envName, config);
509
+ if (!ok) {
510
+ p.log.warn(`Login did not complete. Run ${chalk.bold('align login')} and re-run ${chalk.bold('align setup')}.`);
511
+ process.exit(1);
512
+ }
513
+ // Re-create the client so it carries the freshly stored token.
514
+ client = createGatewayClient(config.getEnvironment(envName));
515
+ }
516
+ else {
517
+ // Declined cloud login: offer the local escape hatch instead of failing.
518
+ const wantLocal = await p.confirm({ message: 'Set up local-only mode instead? (no account, stays on this machine)' });
519
+ if (!p.isCancel(wantLocal) && wantLocal) {
520
+ await runLocalSetup();
521
+ return;
522
+ }
523
+ p.log.warn(`Run ${chalk.bold('align login')} when ready, then ${chalk.bold('align setup')}.`);
524
+ process.exit(1);
525
+ }
526
+ }
527
+ // ---- Step 2: PATH check ----
528
+ try {
529
+ await execa('which', ['align']);
530
+ }
531
+ catch {
532
+ p.log.warn(`The ${chalk.bold('align')} command is not on your PATH. ` +
533
+ `Editor MCP configs won't work until you run: ${chalk.bold('npm install -g @aligndottech/cli')}`);
534
+ }
535
+ // ---- Step 3: MCP editor config (before import - this is the payoff) ----
536
+ console.log('');
537
+ const editors = detectEditors();
538
+ if (editors.length > 0) {
539
+ p.log.info(`Detected ${editors.length} editor${editors.length === 1 ? '' : 's'}: ${editors.map(e => e.name).join(', ')}`);
540
+ let selectedEditors = editors.map(e => e.name);
541
+ if (editors.length > 1) {
542
+ const sel = await p.multiselect({
543
+ message: 'Which editors to configure?',
544
+ options: editors.map(e => ({ value: e.name, label: e.name })),
545
+ });
546
+ if (!p.isCancel(sel))
547
+ selectedEditors = sel;
548
+ }
549
+ for (const name of selectedEditors) {
550
+ const target = editors.find(e => e.name === name);
551
+ try {
552
+ writeMcpConfig(target, envName === 'prod' ? undefined : envName);
553
+ p.log.success(`${name}: align MCP connected`);
554
+ }
555
+ catch (err) {
556
+ p.log.warn(`${name}: ${err.message}`);
557
+ }
558
+ }
559
+ }
560
+ else {
561
+ p.log.info(`No editors detected. Run ${chalk.bold('align mcp --setup')} after installing Claude Code or Cursor.`);
562
+ }
563
+ // ---- Step 3b: Deterministic auto-alignment files (hook + nudges) ----
564
+ writeAgentAlignment(envName);
565
+ // ---- Step 4: Git auto-import (zero-auth baseline graph seed) ----
566
+ let totalDecisions = 0;
567
+ const sourcesImported = [];
568
+ const gitAvailable = await isGitRepo();
569
+ if (gitAvailable) {
570
+ console.log('');
571
+ const gitSpinner = p.spinner();
572
+ gitSpinner.start('Scanning git history...');
234
573
  try {
235
- const existing = await client.listDecisionLinks();
236
- preImportLinkCount = existing.length;
574
+ const gitSource = buildSources(true).find(s => s.id === 'git');
575
+ const items = await gitSource.fetch({});
576
+ // Stop the scan spinner before runPersonalImport - it starts its own
577
+ // progress spinner, and two animated spinners on one line flicker.
578
+ if (items.length) {
579
+ gitSpinner.stop(`Found ${items.length} commits worth importing`);
580
+ const ingested = await runPersonalImport(items, client, {
581
+ label: 'Git',
582
+ approve: true,
583
+ appUrl: resolveAppUrl(env),
584
+ });
585
+ totalDecisions += ingested;
586
+ if (ingested > 0)
587
+ sourcesImported.push('Git');
588
+ }
589
+ else {
590
+ gitSpinner.stop('No decisions found in git history');
591
+ }
237
592
  }
238
593
  catch {
239
- // Non-fatal - delta will just be the post-import total
594
+ gitSpinner.stop('Git import skipped');
240
595
  }
241
- // ---- Step 2: Source selection ----
596
+ }
597
+ // ---- Step 5: First-query prompt ----
598
+ if (editors.length > 0) {
242
599
  console.log('');
243
- const gitAvailable = await isGitRepo();
244
- const allSources = buildSources(gitAvailable);
245
- const sourceOptions = allSources.map(s => ({
246
- value: s.id,
247
- label: s.label,
248
- hint: s.description,
249
- }));
250
- const selectedIds = await p.multiselect({
251
- message: 'Which sources do you want to import? (space to select, enter to confirm)',
252
- options: sourceOptions,
253
- required: true,
254
- initialValues: gitAvailable ? ['git'] : [],
255
- });
256
- if (p.isCancel(selectedIds)) {
257
- p.cancel('Cancelled.');
258
- process.exit(0);
259
- }
260
- const selectedSources = allSources.filter(s => selectedIds.includes(s.id));
261
- // ---- Step 3: Token collection + import per source ----
262
- let totalDecisions = 0;
263
- const sourcesImported = [];
264
- for (const source of selectedSources) {
265
- console.log('');
266
- p.log.step(chalk.bold(source.label));
267
- // Collect tokens - OAuth browser flow for supported connectors, manual paste otherwise
268
- let tokens = {};
269
- if (source.oauthKey) {
270
- const collected = await collectTokensViaOAuth(source, client, config, envName, opts.reset ?? false);
271
- if (!collected) {
272
- p.log.warn(`Skipping ${source.label} - no token obtained.`);
273
- continue;
274
- }
275
- tokens = collected;
600
+ p.log.info(chalk.dim('Your agent is connected. Try asking:'));
601
+ p.log.info(chalk.bold(' "What decisions exist in this codebase?"'));
602
+ }
603
+ // ---- Step 6: Optional connectors ----
604
+ // Order by OAuth scope tier so frictionless personal-account connectors come
605
+ // first, then Atlassian (site-scoped), then workspace-admin (Slack/Teams).
606
+ console.log('');
607
+ const connectorSources = buildSources(false)
608
+ .filter(s => s.id !== 'git')
609
+ .sort((a, b) => TIER_ORDER[a.tier ?? 'personal'] - TIER_ORDER[b.tier ?? 'personal']);
610
+ const selectedIds = await p.multiselect({
611
+ message: 'Connect more sources for richer context? (skip to finish)',
612
+ options: connectorSources.map(s => ({ value: s.id, label: s.label, hint: s.description })),
613
+ required: false,
614
+ });
615
+ if (p.isCancel(selectedIds)) {
616
+ p.cancel('Cancelled.');
617
+ process.exit(0);
618
+ }
619
+ const selectedSources = connectorSources.filter(s => selectedIds.includes(s.id));
620
+ // ---- Step 7a: Collect all credentials up front (consents back-to-back) ----
621
+ // Interactive auth (browser OAuth, token paste) can only happen one at a
622
+ // time, so we gather every connector's creds first instead of interleaving
623
+ // a slow fetch+import between each sign-in.
624
+ const readyConnectors = [];
625
+ // OAuth keys connected during this run, so an Atlassian sibling (Jira <->
626
+ // Confluence, one shared app + token) reuses the token instead of opening a
627
+ // second browser - even under --reset.
628
+ const connectedThisRun = new Set();
629
+ for (const source of selectedSources) {
630
+ console.log('');
631
+ p.log.step(chalk.bold(source.label));
632
+ let tokens = {};
633
+ if (source.oauthKey && source.hostGatedOAuth) {
634
+ // Host-gated: blank host field → OAuth (SaaS default); a self-managed host
635
+ // → token-paste fallback (the fixed OAuth app can't serve arbitrary hosts).
636
+ const gate = source.hostGatedOAuth.field;
637
+ const gateLabel = source.extraFields?.find((f) => f.key === gate)?.label ?? gate;
638
+ const host = await p.text({ message: ` ${gateLabel}:`, placeholder: 'gitlab.com', defaultValue: '' });
639
+ if (p.isCancel(host)) {
640
+ p.cancel('Cancelled.');
641
+ process.exit(0);
276
642
  }
277
- else if (source.tokenLabel || (source.extraFields?.length ?? 0) > 0) {
278
- const collected = await collectTokens(source);
643
+ // p.text returns undefined on a blank submit (not ''), so coerce before trim.
644
+ const hostValue = (typeof host === 'string' ? host : '').trim();
645
+ if (hostValue) {
646
+ // self-managed → PAT. Seed the host so tokenUrl() targets it, and drop the
647
+ // gate field from extraFields so we don't re-ask it.
648
+ const patSource = { ...source, extraFields: source.extraFields?.filter((f) => f.key !== gate) };
649
+ const collected = await collectTokens(patSource, { [gate]: hostValue });
279
650
  if (!collected) {
280
651
  p.cancel('Cancelled.');
281
652
  process.exit(0);
282
653
  }
283
654
  tokens = collected;
284
655
  }
285
- // Fetch items (with one inline re-auth retry on auth expiry)
286
- const fetchSpinner = p.spinner();
287
- fetchSpinner.start(`Fetching from ${source.label}...`);
288
- let items = [];
289
- try {
290
- items = await source.fetch(tokens);
291
- fetchSpinner.stop(`Found ${items.length} items`);
292
- }
293
- catch (err) {
294
- if (err instanceof AuthExpiredError && source.oauthKey) {
295
- fetchSpinner.stop(`${source.label} token expired.`);
296
- const reauth = await p.confirm({ message: `Reconnect ${source.label} now?` });
297
- if (!p.isCancel(reauth) && reauth) {
298
- const fresh = await collectTokensViaOAuth(source, client, config, envName, true);
299
- if (fresh) {
300
- try {
301
- fetchSpinner.start(`Retrying ${source.label}...`);
302
- items = await source.fetch(fresh);
303
- fetchSpinner.stop(`Found ${items.length} items`);
304
- tokens = fresh;
305
- }
306
- catch (retryErr) {
307
- fetchSpinner.stop(`Still failed: ${retryErr.message}`);
308
- continue;
309
- }
310
- }
311
- else {
312
- p.log.warn(`Skipping ${source.label} - re-auth cancelled or failed.`);
313
- continue;
314
- }
315
- }
316
- else {
317
- p.log.warn(`Skipping ${source.label}. Run ${chalk.bold('align setup')} to reconnect.`);
318
- continue;
319
- }
320
- }
321
- else {
322
- fetchSpinner.stop(`Skipped ${source.label} - ${err.message}`);
323
- p.log.warn(`You can run ${chalk.bold(`align import ${source.id}`)} later to retry.`);
656
+ else {
657
+ const collected = await collectTokensViaOAuth(source, client, config, envName, opts.reset ?? false, connectedThisRun);
658
+ if (!collected) {
659
+ p.log.warn(`Skipping ${source.label} - no token obtained.`);
324
660
  continue;
325
661
  }
662
+ tokens = collected;
326
663
  }
327
- if (!items.length) {
328
- p.log.warn(`No items found in ${source.label}.`);
664
+ }
665
+ else if (source.oauthKey) {
666
+ const collected = await collectTokensViaOAuth(source, client, config, envName, opts.reset ?? false, connectedThisRun);
667
+ if (!collected) {
668
+ p.log.warn(`Skipping ${source.label} - no token obtained.`);
329
669
  continue;
330
670
  }
331
- // Import (always --approve inside setup to avoid double-confirmation)
332
- const ingested = await runPersonalImport(items, client, {
333
- label: source.label,
334
- approve: true,
335
- appUrl: resolveAppUrl(env),
336
- });
337
- totalDecisions += ingested;
338
- if (ingested > 0)
339
- sourcesImported.push(source.label);
671
+ tokens = collected;
340
672
  }
341
- if (!totalDecisions) {
342
- p.log.warn('No decisions imported. Run individual align import commands to try specific sources.');
343
- p.outro('Setup complete (no data imported).');
344
- return;
673
+ else if (source.tokenLabel || (source.extraFields?.length ?? 0) > 0) {
674
+ const collected = await collectTokens(source);
675
+ if (!collected) {
676
+ p.cancel('Cancelled.');
677
+ process.exit(0);
678
+ }
679
+ tokens = collected;
345
680
  }
346
- // ---- Step 4: Relationship count (delta from pre-import baseline) ----
347
- console.log('');
348
- const linkSpinner = p.spinner();
349
- linkSpinner.start('Mapping cross-tool relationships...');
350
- await new Promise(r => setTimeout(r, 4000));
351
- let linkCount = 0;
681
+ readyConnectors.push({ source, tokens });
682
+ }
683
+ const n = readyConnectors.length;
684
+ const fetchSpinner = p.spinner();
685
+ fetchSpinner.start(`Fetching from ${n} source${n === 1 ? '' : 's'}...`);
686
+ // Each task catches its own errors so one slow or failing connector never
687
+ // blocks the others. AuthExpiredError is flagged for interactive re-auth below.
688
+ const fetched = await Promise.all(readyConnectors.map(async ({ source, tokens }) => {
352
689
  try {
353
- const links = await client.listDecisionLinks();
354
- linkCount = Math.max(0, links.length - preImportLinkCount);
355
- if (linkCount > 0) {
356
- linkSpinner.stop(`${linkCount} new cross-tool link${linkCount === 1 ? '' : 's'} found`);
357
- }
358
- else {
359
- linkSpinner.stop('Relationship mapping running in background - check align links list shortly');
690
+ return { source, items: await source.fetch(tokens) };
691
+ }
692
+ catch (err) {
693
+ if (err instanceof AuthExpiredError && source.oauthKey) {
694
+ return { source, authExpired: true };
360
695
  }
696
+ return { source, error: err };
361
697
  }
362
- catch {
363
- linkSpinner.stop('Check align links list for cross-tool relationships');
698
+ }));
699
+ fetchSpinner.stop(`Fetched ${n} source${n === 1 ? '' : 's'}`);
700
+ // Resolve any expired-token connectors interactively first (sequential, and
701
+ // rare - 7a just minted fresh tokens), collecting everything ready to import.
702
+ const ready = [];
703
+ for (const result of fetched) {
704
+ const source = result.source;
705
+ if ('items' in result) {
706
+ if (result.items.length)
707
+ ready.push({ source, items: result.items });
708
+ else
709
+ p.log.warn(`No items found in ${source.label}.`);
364
710
  }
365
- // ---- Step 5: MCP setup ----
366
- console.log('');
367
- const editors = detectEditors();
368
- if (editors.length > 0) {
369
- p.log.info(`Detected ${editors.length} editor${editors.length === 1 ? '' : 's'}: ${editors.map(e => e.name).join(', ')}`);
370
- const setupMcp = await p.confirm({ message: 'Configure MCP so Claude/Cursor can query your graph inline?' });
371
- if (!p.isCancel(setupMcp) && setupMcp) {
372
- let selected = editors.map(e => e.name);
373
- if (editors.length > 1) {
374
- const sel = await p.multiselect({
375
- message: 'Which editors to configure?',
376
- options: editors.map(e => ({ value: e.name, label: e.name })),
377
- });
378
- if (!p.isCancel(sel))
379
- selected = sel;
380
- }
381
- for (const name of selected) {
382
- const target = editors.find(e => e.name === name);
383
- try {
384
- writeMcpConfig(target, envName === 'prod' ? undefined : envName);
385
- p.log.success(`${name}: align added to MCP servers`);
386
- }
387
- catch (err) {
388
- p.log.warn(`${name}: ${err.message}`);
389
- }
711
+ else if ('authExpired' in result) {
712
+ // Jira + Confluence share one Atlassian OAuth app, so a single consent
713
+ // reconnects both. If a sibling already reconnected this connector earlier
714
+ // in this loop, its token is fresh - reuse it silently rather than prompting
715
+ // and opening a second browser flow.
716
+ const alreadyReconnected = source.oauthKey ? connectedThisRun.has(source.oauthKey) : false;
717
+ if (!alreadyReconnected) {
718
+ const reauth = await p.confirm({ message: `${oauthFlowLabel(source)} token expired. Reconnect now?` });
719
+ if (p.isCancel(reauth) || !reauth) {
720
+ p.log.warn(`Skipping ${source.label}. Run ${chalk.bold('align setup')} to reconnect.`);
721
+ continue;
390
722
  }
391
- console.log('');
392
- p.log.info(chalk.dim('Restart your editor, then ask:'));
393
- p.log.info(chalk.dim(` "What has my team decided about ${sourcesImported[0] ? `our ${sourcesImported[0].toLowerCase()} workflow` : 'authentication'}?"`));
723
+ }
724
+ // reset = !alreadyReconnected: force a fresh consent for the first connector,
725
+ // but reuse the shared token (no browser) for the sibling.
726
+ const fresh = await collectTokensViaOAuth(source, client, config, envName, !alreadyReconnected, connectedThisRun);
727
+ if (!fresh) {
728
+ p.log.warn(`Skipping ${source.label} - re-auth cancelled or failed.`);
729
+ continue;
730
+ }
731
+ const retrySpinner = p.spinner();
732
+ retrySpinner.start(`Retrying ${source.label}...`);
733
+ try {
734
+ const items = await source.fetch(fresh);
735
+ retrySpinner.stop(`Found ${items.length} items`);
736
+ if (items.length)
737
+ ready.push({ source, items });
738
+ else
739
+ p.log.warn(`No items found in ${source.label}.`);
740
+ }
741
+ catch (retryErr) {
742
+ retrySpinner.stop(`Still failed: ${retryErr.message}`);
394
743
  }
395
744
  }
396
745
  else {
397
- p.log.info(`To use Align inside Claude or Cursor, run: ${chalk.bold('align mcp --setup')}`);
746
+ p.log.warn(`Skipped ${source.label} - ${result.error.message}`);
747
+ p.log.warn(`You can run ${chalk.bold(`align import ${source.id}`)} later to retry.`);
398
748
  }
399
- // ---- Outro ----
400
- const linkLine = linkCount > 0
401
- ? ` ${linkCount} cross-tool link${linkCount === 1 ? '' : 's'} found\n`
402
- : '';
403
- const sourceLine = sourcesImported.length > 0
404
- ? ` ${sourcesImported.length} source${sourcesImported.length === 1 ? '' : 's'}: ${sourcesImported.join(', ')}\n`
405
- : '';
406
- const outroText = [
407
- chalk.bold('Setup complete.\n'),
408
- ` ${totalDecisions} decisions captured`,
409
- sourceLine ? `\n${sourceLine}` : '',
410
- linkLine ? `\n${linkLine}` : '',
411
- `\n Run: ${chalk.bold('align ask "any question about your codebase"')}\n`,
412
- chalk.dim('\n Want your whole team to have a shared decision graph?'),
413
- chalk.dim('\n https://align.tech/pricing'),
414
- ].join('');
415
- p.outro(outroText);
416
- });
749
+ }
750
+ // Import every ready connector CONCURRENTLY - the imports (gateway ingest +
751
+ // analysis) are the long pole, so they run in parallel (bounded) in quiet mode.
752
+ // Each prints one compact completion line; the shared footer prints once after.
753
+ if (ready.length) {
754
+ console.log('');
755
+ p.log.step(`Importing from ${ready.length} source${ready.length === 1 ? '' : 's'} in parallel...`);
756
+ const importResults = await runWithConcurrency(ready.map(({ source, items }) => async () => {
757
+ const total = await runPersonalImport(items, client, {
758
+ label: source.label,
759
+ approve: true,
760
+ appUrl: resolveAppUrl(env),
761
+ quiet: true,
762
+ // Async ingest (ALI-114): return at DB-write speed; titles + links
763
+ // enrich in the background. Connection counts show as 0 here and fill in later.
764
+ deferEnrichment: true,
765
+ });
766
+ return { label: source.label, total };
767
+ }), IMPORT_CONCURRENCY);
768
+ for (const r of importResults) {
769
+ if (r.status === 'fulfilled') {
770
+ totalDecisions += r.value.total;
771
+ if (r.value.total > 0)
772
+ sourcesImported.push(r.value.label);
773
+ }
774
+ else {
775
+ p.log.warn(`Import failed: ${r.reason.message}`);
776
+ }
777
+ }
778
+ console.log('');
779
+ console.log(chalk.dim('Relationships across all your imported tools are detected automatically in the background.'));
780
+ console.log(chalk.dim('Query your graph: align ask "..." or align decisions list'));
781
+ console.log('');
782
+ }
783
+ // ---- Outro ----
784
+ const decisionsLine = totalDecisions > 0
785
+ ? ` ${totalDecisions} decisions in your graph`
786
+ : ` No decisions yet - run ${chalk.bold('align import')} to load your history`;
787
+ const sourceLine = sourcesImported.length > 0
788
+ ? `\n Sources: ${sourcesImported.join(', ')}`
789
+ : '';
790
+ const outroText = [
791
+ chalk.bold('Setup complete.\n'),
792
+ decisionsLine,
793
+ sourceLine,
794
+ `\n\n Run: ${chalk.bold('align ask "any question about your codebase"')}`,
795
+ chalk.dim('\n\n Want your whole team on a shared decision graph?'),
796
+ chalk.dim('\n Upgrade by accepting a team invite - your decisions come with you'),
797
+ chalk.dim('\n (you reconnect your connectors once in the team workspace).'),
798
+ chalk.dim('\n https://app.align.tech/pricing'),
799
+ ].join('');
800
+ p.outro(outroText);
417
801
  }
418
802
  //# sourceMappingURL=setup.js.map