@catalyst-cloud/cli 0.14.6 → 0.14.7
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/CHANGELOG.md +5 -0
- package/README.md +5 -3
- package/dist/args.js +51 -13
- package/dist/cli.js +58 -10
- package/dist/config.js +16 -3
- package/dist/daemon-authority-lifetime.js +171 -0
- package/dist/daemon-authority.js +216 -0
- package/dist/daemon-cache.js +348 -0
- package/dist/daemon-store.js +370 -0
- package/dist/daemon-supervisor.js +460 -0
- package/dist/env/onboard-draft-store.js +243 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +203 -0
- package/dist/events-cloud-tail.js +134 -0
- package/dist/events-cloud.js +500 -0
- package/dist/events.js +13 -4
- package/dist/oauth.js +125 -33
- package/dist/onboard-accounts.js +259 -0
- package/dist/onboard-automation-management.js +15 -0
- package/dist/onboard-bootstrap.js +162 -0
- package/dist/onboard-capabilities.js +15 -7
- package/dist/onboard-capacity.js +226 -0
- package/dist/onboard-existing.js +106 -32
- package/dist/onboard-file-snapshot.js +45 -0
- package/dist/onboard-login-candidate.js +261 -0
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +147 -37
- package/dist/onboard-runtime.js +104 -9
- package/dist/onboard-settings-context.js +141 -0
- package/dist/onboard-settings-import.js +127 -0
- package/dist/onboard-settings.js +234 -0
- package/dist/onboard-ui.js +99 -11
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard.js +359 -27
- package/dist/owned-fetch.js +182 -0
- package/dist/query.js +77 -27
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +2 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
- package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
- package/skills/catalyst-linear/SKILL.md +3 -3
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +5 -4
- package/skills/catalyst-onboard/references/local-sync.md +3 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +2 -2
- package/skills/whats-happening/references/status-reply.md +1 -1
- package/skills/whats-happening/scripts/snapshot.mjs +104 -26
- package/vendor/settings/index.d.ts +4 -0
- package/vendor/settings/index.js +2937 -0
- package/vendor/settings/provenance.json +148 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { constants, closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
/** Bounded, regular-file snapshot for the review precheck. This is not a CAS primitive. */
|
|
4
|
+
export function onboardFileSnapshot(path) {
|
|
5
|
+
let fd;
|
|
6
|
+
try {
|
|
7
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (error !== null &&
|
|
11
|
+
typeof error === "object" &&
|
|
12
|
+
"code" in error &&
|
|
13
|
+
error.code === "ENOENT")
|
|
14
|
+
return null;
|
|
15
|
+
throw new CliError("The saved setup files could not be verified. Keep them and try again.", "onboard-file-unverified", 12);
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const before = fstatSync(fd);
|
|
19
|
+
const limit = 1024 * 1024;
|
|
20
|
+
if (!before.isFile() || before.size > limit)
|
|
21
|
+
throw new Error("invalid file");
|
|
22
|
+
const bytes = Buffer.alloc(limit + 1);
|
|
23
|
+
let used = 0;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const read = readSync(fd, bytes, used, bytes.length - used, null);
|
|
26
|
+
used += read;
|
|
27
|
+
if (used > limit)
|
|
28
|
+
throw new Error("oversized file");
|
|
29
|
+
if (read === 0)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
const after = fstatSync(fd);
|
|
33
|
+
if (before.size !== after.size ||
|
|
34
|
+
before.mtimeMs !== after.mtimeMs ||
|
|
35
|
+
used !== after.size)
|
|
36
|
+
throw new Error("changed file");
|
|
37
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new CliError("The saved setup files could not be verified. Keep them and try again.", "onboard-file-unverified", 12);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
closeSync(fd);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { constants, closeSync, fsyncSync, openSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { DEFAULT_BASE_URL, cliPath, configPathFor, contractPathFor, defaultReplicaDbFor, loadConfig, normalizeBaseUrl, readManifest, writeConfig, } from "./config.js";
|
|
4
|
+
import { contractVersionInRange } from "./contract.js";
|
|
5
|
+
import { CliError, UsageError } from "./errors.js";
|
|
6
|
+
import { deviceFlowLogin } from "./oauth.js";
|
|
7
|
+
import { fetchMe } from "./transport.js";
|
|
8
|
+
import { onboardFileSnapshot } from "./onboard-file-snapshot.js";
|
|
9
|
+
const stopped = () => new CliError("Sign-in paused. Your saved connection was not changed.", "onboard-signin-paused", 11);
|
|
10
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
11
|
+
? value
|
|
12
|
+
: null;
|
|
13
|
+
async function bounded(external, ms, run) {
|
|
14
|
+
const owned = new AbortController();
|
|
15
|
+
const signal = external
|
|
16
|
+
? AbortSignal.any([owned.signal, external])
|
|
17
|
+
: owned.signal;
|
|
18
|
+
const timer = setTimeout(() => owned.abort(), ms);
|
|
19
|
+
let remove = () => { };
|
|
20
|
+
try {
|
|
21
|
+
return await new Promise((resolve, reject) => {
|
|
22
|
+
const abort = () => reject(stopped());
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
abort();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
28
|
+
remove = () => signal.removeEventListener("abort", abort);
|
|
29
|
+
Promise.resolve()
|
|
30
|
+
.then(() => {
|
|
31
|
+
if (signal.aborted)
|
|
32
|
+
throw stopped();
|
|
33
|
+
return run(signal);
|
|
34
|
+
})
|
|
35
|
+
.then((value) => (signal.aborted ? abort() : resolve(value)), reject);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
remove();
|
|
41
|
+
owned.abort();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function boundedResponse(response, signal) {
|
|
45
|
+
if (!response.body)
|
|
46
|
+
return response;
|
|
47
|
+
let bytes = 0;
|
|
48
|
+
const limit = new TransformStream({
|
|
49
|
+
transform(chunk, controller) {
|
|
50
|
+
if (signal.aborted)
|
|
51
|
+
throw stopped();
|
|
52
|
+
bytes += chunk.byteLength;
|
|
53
|
+
if (bytes > 1024 * 1024)
|
|
54
|
+
throw new CliError("The sign-in response could not be verified.", "onboard-response-unverified", 11);
|
|
55
|
+
controller.enqueue(chunk);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
return new Response(response.body.pipeThrough(limit), {
|
|
59
|
+
status: response.status,
|
|
60
|
+
statusText: response.statusText,
|
|
61
|
+
headers: response.headers,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Staging keeps tokens in this closure. Neither config nor receipt nor auth caches is changed. */
|
|
65
|
+
export async function stageOnboardLogin(ctx, input) {
|
|
66
|
+
const timeoutMs = input.timeoutMs ?? 600_000;
|
|
67
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 600_000)
|
|
68
|
+
throw new UsageError("onboarding sign-in timeout must be 1 to 600000 milliseconds");
|
|
69
|
+
const reviewDeadline = Date.now() + timeoutMs;
|
|
70
|
+
const original = onboardFileSnapshot(configPathFor(ctx.home));
|
|
71
|
+
const saved = loadConfig(ctx.home);
|
|
72
|
+
const originalContract = onboardFileSnapshot(contractPathFor(ctx.home));
|
|
73
|
+
const baseUrl = normalizeBaseUrl(saved?.baseUrl ??
|
|
74
|
+
input.baseUrl ??
|
|
75
|
+
ctx.env.CATALYST_CLOUD_BASE_URL ??
|
|
76
|
+
DEFAULT_BASE_URL);
|
|
77
|
+
if (input.baseUrl && normalizeBaseUrl(input.baseUrl) !== baseUrl)
|
|
78
|
+
throw new CliError("Resume against the original cloud to change this login.", "onboard-base-url-mismatch", 12);
|
|
79
|
+
const url = new URL(baseUrl);
|
|
80
|
+
if (url.protocol !== "https:" || url.origin !== baseUrl)
|
|
81
|
+
throw new CliError("The saved cloud origin could not be verified.", "onboard-base-url-mismatch", 12);
|
|
82
|
+
const manifest = readManifest();
|
|
83
|
+
const validate = async (auth, signal) => {
|
|
84
|
+
if (signal.aborted)
|
|
85
|
+
throw stopped();
|
|
86
|
+
if (!Number.isFinite(Date.parse(auth.expiresAt)) ||
|
|
87
|
+
Date.parse(auth.expiresAt) - ctx.now().getTime() <= 30_000)
|
|
88
|
+
throw new CliError("Sign in again before accepting this plan.", "onboard-login-refresh-required", 11);
|
|
89
|
+
const fetchImpl = async (request, init) => boundedResponse(await ctx.fetch(request, {
|
|
90
|
+
...init,
|
|
91
|
+
redirect: "error",
|
|
92
|
+
signal: init?.signal
|
|
93
|
+
? AbortSignal.any([signal, init.signal])
|
|
94
|
+
: signal,
|
|
95
|
+
}), signal);
|
|
96
|
+
const me = await fetchMe(baseUrl, auth.accessToken, fetchImpl);
|
|
97
|
+
if (signal.aborted)
|
|
98
|
+
throw stopped();
|
|
99
|
+
if (!me.user)
|
|
100
|
+
throw new CliError("Onboarding needs your personal login.", "onboard-person-required", 12);
|
|
101
|
+
// Cached contracts and loadContract's stale fallback cannot approve a staged identity.
|
|
102
|
+
const response = await fetchImpl(`${baseUrl}/api/v1/agent/contract`, {
|
|
103
|
+
headers: {
|
|
104
|
+
authorization: `Bearer ${auth.accessToken}`,
|
|
105
|
+
accept: "application/json",
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
if (!response.ok)
|
|
109
|
+
throw new CliError("The workspace could not be verified. Your saved connection was not changed.", "onboard-candidate-unverified", 11);
|
|
110
|
+
const text = await response.text();
|
|
111
|
+
if (signal.aborted)
|
|
112
|
+
throw stopped();
|
|
113
|
+
if (Buffer.byteLength(text) > 1024 * 1024)
|
|
114
|
+
throw new CliError("The workspace response could not be verified.", "onboard-candidate-unverified", 11);
|
|
115
|
+
let contract;
|
|
116
|
+
try {
|
|
117
|
+
contract = object(JSON.parse(text));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
contract = null;
|
|
121
|
+
}
|
|
122
|
+
if (object(contract?.account)?.id !== me.account ||
|
|
123
|
+
typeof contract?.contractVersion !== "string" ||
|
|
124
|
+
contractVersionInRange(contract.contractVersion, manifest.tenantContractRange) !== true)
|
|
125
|
+
throw new CliError("The workspace response did not match this login.", "onboard-candidate-unverified", 11);
|
|
126
|
+
return me;
|
|
127
|
+
};
|
|
128
|
+
const staged = await bounded(input.signal, timeoutMs, async (signal) => {
|
|
129
|
+
const stageCtx = {
|
|
130
|
+
...ctx,
|
|
131
|
+
env: { ...ctx.env, CATALYST_CLOUD_TOKEN: undefined },
|
|
132
|
+
fetch: async (request, init) => boundedResponse(await ctx.fetch(request, {
|
|
133
|
+
...init,
|
|
134
|
+
redirect: "error",
|
|
135
|
+
signal: init?.signal
|
|
136
|
+
? AbortSignal.any([signal, init.signal])
|
|
137
|
+
: signal,
|
|
138
|
+
}), signal),
|
|
139
|
+
};
|
|
140
|
+
let auth;
|
|
141
|
+
try {
|
|
142
|
+
auth = await deviceFlowLogin(stageCtx, baseUrl, {
|
|
143
|
+
...input.device,
|
|
144
|
+
discoveryCache: false,
|
|
145
|
+
signal,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
if (signal.aborted)
|
|
150
|
+
throw stopped();
|
|
151
|
+
throw new CliError("Sign-in could not be verified. Your saved connection was not changed.", "onboard-signin-unverified", 11);
|
|
152
|
+
}
|
|
153
|
+
const me = await bounded(signal, 30_000, (readSignal) => validate(auth, readSignal));
|
|
154
|
+
if (onboardFileSnapshot(configPathFor(ctx.home)) !== original)
|
|
155
|
+
throw new CliError("Your saved login changed. Run setup again.", "onboard-login-changed", 11);
|
|
156
|
+
return { auth, me };
|
|
157
|
+
});
|
|
158
|
+
const user = staged.me.user;
|
|
159
|
+
if (!user)
|
|
160
|
+
throw new CliError("Onboarding needs your personal login.", "onboard-person-required", 12);
|
|
161
|
+
const identity = {
|
|
162
|
+
account: staged.me.account,
|
|
163
|
+
membershipId: user.id,
|
|
164
|
+
baseUrl,
|
|
165
|
+
role: user.role,
|
|
166
|
+
display: Object.freeze({
|
|
167
|
+
personLabel: user.label,
|
|
168
|
+
email: user.email,
|
|
169
|
+
workspaceName: staged.me.name,
|
|
170
|
+
workspaceSlug: staged.me.slug,
|
|
171
|
+
}),
|
|
172
|
+
};
|
|
173
|
+
let accepted = false;
|
|
174
|
+
return {
|
|
175
|
+
identity: Object.freeze(identity),
|
|
176
|
+
get accepted() {
|
|
177
|
+
return accepted;
|
|
178
|
+
},
|
|
179
|
+
async accept(signal, beforePublish) {
|
|
180
|
+
if (accepted)
|
|
181
|
+
throw new CliError("This sign-in preview was already accepted.", "onboard-candidate-used", 11);
|
|
182
|
+
const remaining = reviewDeadline - Date.now();
|
|
183
|
+
if (remaining <= 0)
|
|
184
|
+
throw stopped();
|
|
185
|
+
const me = await bounded(signal, Math.min(30_000, remaining), (readSignal) => validate(staged.auth, readSignal));
|
|
186
|
+
if (me.account !== identity.account ||
|
|
187
|
+
me.user?.id !== identity.membershipId ||
|
|
188
|
+
me.user.role !== identity.role)
|
|
189
|
+
throw new CliError("This login changed during review. Run setup again.", "onboard-login-changed", 11);
|
|
190
|
+
if (signal?.aborted)
|
|
191
|
+
throw stopped();
|
|
192
|
+
if (onboardFileSnapshot(configPathFor(ctx.home)) !== original)
|
|
193
|
+
throw new CliError("Your saved login changed. Run setup again.", "onboard-login-changed", 11);
|
|
194
|
+
if (onboardFileSnapshot(contractPathFor(ctx.home)) !== originalContract)
|
|
195
|
+
throw new CliError("Your setup files changed during review. Run setup again.", "onboard-login-changed", 11);
|
|
196
|
+
beforePublish?.();
|
|
197
|
+
const config = {
|
|
198
|
+
baseUrl,
|
|
199
|
+
account: me.account,
|
|
200
|
+
slug: me.slug,
|
|
201
|
+
name: me.name,
|
|
202
|
+
principal: me.principal,
|
|
203
|
+
permissions: me.permissions,
|
|
204
|
+
user: me.user,
|
|
205
|
+
auth: staged.auth,
|
|
206
|
+
joinedAt: ctx.now().toISOString(),
|
|
207
|
+
lastSkillBundleVersion: manifest.version,
|
|
208
|
+
cliPath: cliPath(),
|
|
209
|
+
...(saved?.skillsDir ? { skillsDir: saved.skillsDir } : {}),
|
|
210
|
+
// A new workspace never inherits the old workspace's database path.
|
|
211
|
+
replicaDb: saved?.account === me.account
|
|
212
|
+
? (saved.replicaDb ?? defaultReplicaDbFor(ctx.home))
|
|
213
|
+
: undefined,
|
|
214
|
+
};
|
|
215
|
+
// This is an accepted connection, not a two-file transaction. Existing config writers do
|
|
216
|
+
// not share the receipt lock; the precheck refuses visible changes but is not a CAS claim.
|
|
217
|
+
try {
|
|
218
|
+
writeConfig(ctx.home, config);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
const current = loadConfig(ctx.home);
|
|
222
|
+
accepted =
|
|
223
|
+
current?.account === config.account &&
|
|
224
|
+
current.user?.id === config.user?.id &&
|
|
225
|
+
current.auth?.accessToken === config.auth?.accessToken;
|
|
226
|
+
if (accepted)
|
|
227
|
+
throw new CliError("Your connection was accepted, but setup could not be recorded. Run catalyst onboard to resume.", "onboard-connection-accepted", 11);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
accepted = true;
|
|
231
|
+
const fd = openSync(configPathFor(ctx.home), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
232
|
+
try {
|
|
233
|
+
fsyncSync(fd);
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
closeSync(fd);
|
|
237
|
+
}
|
|
238
|
+
const directory = openSync(dirname(configPathFor(ctx.home)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
239
|
+
try {
|
|
240
|
+
fsyncSync(directory);
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
closeSync(directory);
|
|
244
|
+
}
|
|
245
|
+
// The old cache has no identity scope. Invalidate it only after this reviewed connection
|
|
246
|
+
// is accepted, and only while its observed bytes still match. A failure remains partial.
|
|
247
|
+
if (onboardFileSnapshot(contractPathFor(ctx.home)) !== originalContract)
|
|
248
|
+
throw new CliError("Your connection was accepted, but setup could not be recorded. Run catalyst onboard to resume.", "onboard-connection-accepted", 11);
|
|
249
|
+
if (originalContract !== null) {
|
|
250
|
+
unlinkSync(contractPathFor(ctx.home));
|
|
251
|
+
const cacheDirectory = openSync(dirname(contractPathFor(ctx.home)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
252
|
+
try {
|
|
253
|
+
fsyncSync(cacheDirectory);
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
closeSync(cacheDirectory);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { verifyOnboardRoutes } from "./onboard-capabilities.js";
|
|
2
|
+
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
3
|
+
import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
4
|
+
import { existingRepositoryAdapter, } from "./onboard-repositories.js";
|
|
5
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
6
|
+
? value
|
|
7
|
+
: null;
|
|
8
|
+
const part = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
|
9
|
+
const key = (row) => `${row.owner}/${row.name}`.toLowerCase();
|
|
10
|
+
const waiting = (reason) => ({
|
|
11
|
+
state: "waiting",
|
|
12
|
+
reason,
|
|
13
|
+
});
|
|
14
|
+
function repository(value) {
|
|
15
|
+
const row = object(value);
|
|
16
|
+
return row &&
|
|
17
|
+
typeof row.owner === "string" &&
|
|
18
|
+
part.test(row.owner) &&
|
|
19
|
+
typeof row.name === "string" &&
|
|
20
|
+
part.test(row.name)
|
|
21
|
+
? { owner: row.owner, name: row.name }
|
|
22
|
+
: null;
|
|
23
|
+
}
|
|
24
|
+
function pendingSelection(journal) {
|
|
25
|
+
const step = journal.steps.find((row) => row.id === "github.repos" && row.state === "done");
|
|
26
|
+
const raw = step?.evidence?.repository;
|
|
27
|
+
if (typeof raw !== "string" || raw.length > 64_000)
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
const values = JSON.parse(raw);
|
|
31
|
+
if (!Array.isArray(values) ||
|
|
32
|
+
values.length !== 1 ||
|
|
33
|
+
object(values[0])?.teamId !== selectedOnboardTeam(journal))
|
|
34
|
+
return null;
|
|
35
|
+
return repository(values[0]);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function options(ctx, journal, signal) {
|
|
42
|
+
const cfg = loadConfig(ctx.home);
|
|
43
|
+
if (!cfg?.user ||
|
|
44
|
+
!["owner", "admin"].includes(cfg.user.role) ||
|
|
45
|
+
cfg.account !== journal.account ||
|
|
46
|
+
cfg.user.id !== journal.membershipId ||
|
|
47
|
+
!journal.baseUrl ||
|
|
48
|
+
normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
|
|
49
|
+
return { reason: "project_identity_unverified" };
|
|
50
|
+
const personId = cfg.user.id;
|
|
51
|
+
const teamId = selectedOnboardTeam(journal);
|
|
52
|
+
if (!teamId)
|
|
53
|
+
return { reason: "team_selection_unverified" };
|
|
54
|
+
const support = await verifyOnboardRoutes(ctx, journal, [{ method: "GET", path: "/api/v1/me/repositories/options" }], signal);
|
|
55
|
+
if ("reason" in support)
|
|
56
|
+
return support;
|
|
57
|
+
const sameIdentity = () => {
|
|
58
|
+
const current = loadConfig(ctx.home);
|
|
59
|
+
return (current?.user &&
|
|
60
|
+
["owner", "admin"].includes(current.user.role) &&
|
|
61
|
+
current.account === cfg.account &&
|
|
62
|
+
current.user.id === personId &&
|
|
63
|
+
normalizeBaseUrl(current.baseUrl) === normalizeBaseUrl(cfg.baseUrl));
|
|
64
|
+
};
|
|
65
|
+
// The read helper loads its own credential. Reject a switch before that read and again
|
|
66
|
+
// after its body settles, before any provider names become receipt evidence.
|
|
67
|
+
if (!sameIdentity())
|
|
68
|
+
return { reason: "project_identity_unverified" };
|
|
69
|
+
const read = await readExistingOnboardJson(ctx, "/api/v1/me/repositories/options", signal);
|
|
70
|
+
if (!sameIdentity())
|
|
71
|
+
return { reason: "project_identity_unverified" };
|
|
72
|
+
if ("reason" in read)
|
|
73
|
+
return { reason: "project_options_unavailable" };
|
|
74
|
+
const body = object(read.body), linear = object(body?.linear), github = object(body?.github);
|
|
75
|
+
if (!linear ||
|
|
76
|
+
!github ||
|
|
77
|
+
linear.connected !== true ||
|
|
78
|
+
github.connected !== true ||
|
|
79
|
+
linear.error !== null ||
|
|
80
|
+
github.error !== null)
|
|
81
|
+
return { reason: "project_provider_inventory_unavailable" };
|
|
82
|
+
if (!Array.isArray(linear.teams) ||
|
|
83
|
+
linear.teams.length > 1_000 ||
|
|
84
|
+
!Array.isArray(github.repositories) ||
|
|
85
|
+
github.repositories.length > 10_000 ||
|
|
86
|
+
typeof github.truncated !== "boolean" ||
|
|
87
|
+
!Array.isArray(body?.taken) ||
|
|
88
|
+
body.taken.length > 10_000)
|
|
89
|
+
return { reason: "project_options_unverified" };
|
|
90
|
+
const teams = linear.teams.filter((value) => object(value)?.id === teamId);
|
|
91
|
+
const team = object(teams[0]);
|
|
92
|
+
if (teams.length !== 1 ||
|
|
93
|
+
typeof team?.key !== "string" ||
|
|
94
|
+
!/^[A-Za-z0-9_-]{1,64}$/.test(team.key))
|
|
95
|
+
return { reason: "team_selection_unverified" };
|
|
96
|
+
const rows = [], seen = new Set();
|
|
97
|
+
for (const value of github.repositories) {
|
|
98
|
+
const row = repository(value);
|
|
99
|
+
if (!row || seen.has(key(row)))
|
|
100
|
+
return { reason: "project_options_unverified" };
|
|
101
|
+
seen.add(key(row));
|
|
102
|
+
rows.push(row);
|
|
103
|
+
}
|
|
104
|
+
const taken = [];
|
|
105
|
+
for (const value of body.taken) {
|
|
106
|
+
const row = object(value);
|
|
107
|
+
const repo = repository({
|
|
108
|
+
owner: row?.githubRepoOwner,
|
|
109
|
+
name: row?.githubRepoName,
|
|
110
|
+
});
|
|
111
|
+
if (!row ||
|
|
112
|
+
!repo ||
|
|
113
|
+
typeof row.linearTeamId !== "string" ||
|
|
114
|
+
typeof row.status !== "string" ||
|
|
115
|
+
!["active", "archived"].includes(row.status))
|
|
116
|
+
return { reason: "project_options_unverified" };
|
|
117
|
+
taken.push({
|
|
118
|
+
teamId: row.linearTeamId,
|
|
119
|
+
repo,
|
|
120
|
+
active: row.status === "active",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return { rows, taken, teamKey: team.key, truncated: github.truncated };
|
|
124
|
+
}
|
|
125
|
+
/** Q3 records a choice. It does not report an unregistered repository as a cloud binding. */
|
|
126
|
+
export function firstProjectAdapters(args, input = {}) {
|
|
127
|
+
const existing = existingRepositoryAdapter(args, input.chooseExisting);
|
|
128
|
+
let chosen = null;
|
|
129
|
+
const selected = (journal) => {
|
|
130
|
+
const explicit = args.flags.repo;
|
|
131
|
+
if (explicit !== undefined) {
|
|
132
|
+
const values = Array.isArray(explicit) ? explicit : [explicit];
|
|
133
|
+
if (values.length !== 1 || typeof values[0] !== "string")
|
|
134
|
+
return null;
|
|
135
|
+
const parts = values[0].split("/");
|
|
136
|
+
return parts.length === 2
|
|
137
|
+
? repository({ owner: parts[0], name: parts[1] })
|
|
138
|
+
: null;
|
|
139
|
+
}
|
|
140
|
+
return chosen ?? pendingSelection(journal);
|
|
141
|
+
};
|
|
142
|
+
const selectionResult = (row, journal) => ({
|
|
143
|
+
state: "done",
|
|
144
|
+
evidence: {
|
|
145
|
+
repository: JSON.stringify([
|
|
146
|
+
{ ...row, teamId: selectedOnboardTeam(journal) },
|
|
147
|
+
]),
|
|
148
|
+
count: 1,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const binding = async (ctx, journal, signal) => {
|
|
152
|
+
const row = selected(journal);
|
|
153
|
+
if (!row)
|
|
154
|
+
return waiting("repository_selection_unverified");
|
|
155
|
+
return existingRepositoryAdapter({
|
|
156
|
+
...args,
|
|
157
|
+
flags: { ...args.flags, repo: `${row.owner}/${row.name}` },
|
|
158
|
+
}).check(ctx, journal, signal);
|
|
159
|
+
};
|
|
160
|
+
const projectCheck = async (ctx, journal, signal) => {
|
|
161
|
+
const known = await existing.check(ctx, journal, signal);
|
|
162
|
+
if (known.state === "done")
|
|
163
|
+
return known;
|
|
164
|
+
const live = await binding(ctx, journal, signal);
|
|
165
|
+
if (live.state === "done")
|
|
166
|
+
return live;
|
|
167
|
+
if (live.reason !== "repository_inventory_empty")
|
|
168
|
+
return live;
|
|
169
|
+
const row = selected(journal);
|
|
170
|
+
if (!row)
|
|
171
|
+
return waiting("repository_selection_unverified");
|
|
172
|
+
const list = await options(ctx, journal, signal);
|
|
173
|
+
if ("reason" in list)
|
|
174
|
+
return waiting(list.reason);
|
|
175
|
+
const held = list.taken.filter((value) => value.teamId === selectedOnboardTeam(journal) ||
|
|
176
|
+
key(value.repo) === key(row));
|
|
177
|
+
if (held.length)
|
|
178
|
+
return waiting(held.length === 1 &&
|
|
179
|
+
held[0].active &&
|
|
180
|
+
held[0].teamId === selectedOnboardTeam(journal) &&
|
|
181
|
+
key(held[0].repo) === key(row)
|
|
182
|
+
? "project_registration_visibility_pending"
|
|
183
|
+
: "project_binding_conflict");
|
|
184
|
+
return list.rows.some((value) => key(value) === key(row))
|
|
185
|
+
? { state: "pending" }
|
|
186
|
+
: waiting("repository_selection_unverified");
|
|
187
|
+
};
|
|
188
|
+
return {
|
|
189
|
+
"github.repos": {
|
|
190
|
+
check: async (ctx, journal, signal) => {
|
|
191
|
+
const live = await existing.check(ctx, journal, signal);
|
|
192
|
+
if (live.state === "done")
|
|
193
|
+
return live;
|
|
194
|
+
// A names-only Q3 receipt remains the selected choice when registration later becomes
|
|
195
|
+
// visible. Resolve its actual ID without asking the registered-repository picker again.
|
|
196
|
+
if (selected(journal)) {
|
|
197
|
+
const registered = await binding(ctx, journal, signal);
|
|
198
|
+
if (registered.state === "done" ||
|
|
199
|
+
registered.reason !== "repository_inventory_empty")
|
|
200
|
+
return registered;
|
|
201
|
+
}
|
|
202
|
+
if (live.reason !== "repository_inventory_empty")
|
|
203
|
+
return live;
|
|
204
|
+
const list = await options(ctx, journal, signal);
|
|
205
|
+
if ("reason" in list)
|
|
206
|
+
return waiting(list.reason);
|
|
207
|
+
const row = selected(journal);
|
|
208
|
+
if (row)
|
|
209
|
+
return list.rows.some((value) => key(value) === key(row))
|
|
210
|
+
? selectionResult(row, journal)
|
|
211
|
+
: waiting("repository_selection_unverified");
|
|
212
|
+
return input.chooseFirst && list.rows.length
|
|
213
|
+
? { state: "pending" }
|
|
214
|
+
: waiting("repository_choice_required");
|
|
215
|
+
},
|
|
216
|
+
act: async (ctx, journal, signal) => {
|
|
217
|
+
const live = await existing.check(ctx, journal, signal);
|
|
218
|
+
if (live.reason !== "repository_inventory_empty")
|
|
219
|
+
return existing.act ? existing.act(ctx, journal, signal) : live;
|
|
220
|
+
const list = await options(ctx, journal, signal);
|
|
221
|
+
if ("reason" in list)
|
|
222
|
+
return waiting(list.reason);
|
|
223
|
+
if (!input.chooseFirst || !list.rows.length)
|
|
224
|
+
return waiting("repository_choice_required");
|
|
225
|
+
if (list.truncated)
|
|
226
|
+
input.message?.("GitHub returned part of its repository list. Choose a displayed repository, or stop and adjust the App access before resuming.");
|
|
227
|
+
let remove = () => { };
|
|
228
|
+
const answer = await new Promise((resolve, reject) => {
|
|
229
|
+
const stopped = () => resolve(null);
|
|
230
|
+
if (signal?.aborted) {
|
|
231
|
+
stopped();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
signal?.addEventListener("abort", stopped, { once: true });
|
|
235
|
+
remove = () => signal?.removeEventListener("abort", stopped);
|
|
236
|
+
Promise.resolve()
|
|
237
|
+
.then(() => signal?.aborted
|
|
238
|
+
? null
|
|
239
|
+
: input.chooseFirst(list.rows.map((row) => ({ ...row }))))
|
|
240
|
+
.then(resolve, reject);
|
|
241
|
+
}).finally(() => remove());
|
|
242
|
+
if (signal?.aborted || answer === null)
|
|
243
|
+
return waiting("interrupted");
|
|
244
|
+
chosen =
|
|
245
|
+
list.rows.find((row) => key(row) === answer.toLowerCase()) ?? null;
|
|
246
|
+
if (!chosen)
|
|
247
|
+
return waiting("repository_selection_unverified");
|
|
248
|
+
const fresh = await options(ctx, journal, signal);
|
|
249
|
+
return "reason" in fresh
|
|
250
|
+
? waiting(fresh.reason)
|
|
251
|
+
: fresh.rows.some((row) => key(row) === key(chosen))
|
|
252
|
+
? selectionResult(chosen, journal)
|
|
253
|
+
: waiting("repository_selection_unverified");
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
projects: {
|
|
257
|
+
check: projectCheck,
|
|
258
|
+
act: async (ctx, journal, signal) => {
|
|
259
|
+
const before = await projectCheck(ctx, journal, signal);
|
|
260
|
+
if (before.state !== "pending")
|
|
261
|
+
return before;
|
|
262
|
+
const row = selected(journal);
|
|
263
|
+
if (!row)
|
|
264
|
+
return waiting("repository_selection_unverified");
|
|
265
|
+
const list = await options(ctx, journal, signal);
|
|
266
|
+
if ("reason" in list)
|
|
267
|
+
return waiting(list.reason);
|
|
268
|
+
// Recheck after the second provider read before any mutation; an archived holder is a conflict.
|
|
269
|
+
if (!list.rows.some((value) => key(value) === key(row)) ||
|
|
270
|
+
list.taken.some((value) => value.teamId === selectedOnboardTeam(journal) ||
|
|
271
|
+
key(value.repo) === key(row)))
|
|
272
|
+
return projectCheck(ctx, journal, signal);
|
|
273
|
+
const result = await createFirstProject(ctx, journal, row, list.teamKey, signal);
|
|
274
|
+
if (result)
|
|
275
|
+
return waiting(result);
|
|
276
|
+
return projectCheck(ctx, journal, signal);
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
async function createFirstProject(ctx, journal, row, teamKey, external) {
|
|
282
|
+
const support = await verifyOnboardRoutes(ctx, journal, [{ method: "POST", path: "/api/v1/me/repositories" }], external);
|
|
283
|
+
if ("reason" in support)
|
|
284
|
+
return support.reason;
|
|
285
|
+
const cfg = loadConfig(ctx.home);
|
|
286
|
+
if (!cfg?.user ||
|
|
287
|
+
!["admin", "owner"].includes(cfg.user.role) ||
|
|
288
|
+
cfg.account !== journal.account ||
|
|
289
|
+
cfg.user.id !== journal.membershipId ||
|
|
290
|
+
!journal.baseUrl ||
|
|
291
|
+
normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
|
|
292
|
+
return "project_identity_unverified";
|
|
293
|
+
const expiry = cfg.auth
|
|
294
|
+
? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
|
|
295
|
+
: 0;
|
|
296
|
+
const bearer = cfg.key ||
|
|
297
|
+
(cfg.auth && Number.isFinite(expiry) && expiry > 30_000
|
|
298
|
+
? cfg.auth.accessToken
|
|
299
|
+
: undefined);
|
|
300
|
+
if (!bearer)
|
|
301
|
+
return "project_login_refresh_required";
|
|
302
|
+
const owned = new AbortController(), signal = external
|
|
303
|
+
? AbortSignal.any([external, owned.signal])
|
|
304
|
+
: owned.signal;
|
|
305
|
+
const timer = setTimeout(() => owned.abort(), 30_000);
|
|
306
|
+
let remove = () => { };
|
|
307
|
+
try {
|
|
308
|
+
return await new Promise((resolve) => {
|
|
309
|
+
const stopped = () => resolve(external?.aborted ? "interrupted" : "project_create_unverified");
|
|
310
|
+
if (signal.aborted) {
|
|
311
|
+
stopped();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
signal.addEventListener("abort", stopped, { once: true });
|
|
315
|
+
remove = () => signal.removeEventListener("abort", stopped);
|
|
316
|
+
Promise.resolve()
|
|
317
|
+
.then(async () => {
|
|
318
|
+
if (signal.aborted)
|
|
319
|
+
return "interrupted";
|
|
320
|
+
const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}/api/v1/me/repositories`, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
redirect: "error",
|
|
323
|
+
signal,
|
|
324
|
+
headers: {
|
|
325
|
+
authorization: `Bearer ${bearer}`,
|
|
326
|
+
"content-type": "application/json",
|
|
327
|
+
accept: "application/json",
|
|
328
|
+
},
|
|
329
|
+
body: JSON.stringify({
|
|
330
|
+
name: `${row.owner}/${row.name}`.slice(0, 80),
|
|
331
|
+
linearTeamId: selectedOnboardTeam(journal),
|
|
332
|
+
linearTeamKey: teamKey,
|
|
333
|
+
githubRepoOwner: row.owner,
|
|
334
|
+
githubRepoName: row.name,
|
|
335
|
+
}),
|
|
336
|
+
});
|
|
337
|
+
// A response is not readiness evidence. The next check reads the binding and contract again.
|
|
338
|
+
return response.status === 201 || response.status === 409
|
|
339
|
+
? null
|
|
340
|
+
: "project_create_unverified";
|
|
341
|
+
})
|
|
342
|
+
.then((result) => (signal.aborted ? stopped() : resolve(result)), () => signal.aborted ? stopped() : resolve("project_create_unverified"));
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
remove();
|
|
348
|
+
owned.abort();
|
|
349
|
+
}
|
|
350
|
+
}
|