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