@catalyst-cloud/cli 0.14.6 → 0.14.8
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 +14 -0
- package/README.md +7 -5
- package/dist/args.js +68 -18
- package/dist/browser.js +37 -3
- package/dist/cli.js +58 -12
- package/dist/config.js +17 -5
- package/dist/consent-browser.js +58 -0
- 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 +250 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +205 -0
- package/dist/events-cloud-tail.js +146 -0
- package/dist/events-cloud.js +619 -0
- package/dist/events.js +186 -31
- package/dist/oauth.js +129 -34
- 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-github.js +14 -3
- package/dist/onboard-login-candidate.js +261 -0
- package/dist/onboard-next.js +139 -0
- package/dist/onboard-personal.js +14 -3
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +146 -37
- package/dist/onboard-runtime.js +108 -13
- 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 +85 -59
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard-workspace.js +14 -3
- package/dist/onboard.js +422 -39
- package/dist/owned-fetch.js +182 -0
- package/dist/pagination.js +13 -1
- package/dist/query.js +104 -31
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/repository-id.js +5 -0
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +4 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- package/skills/catalyst-github/references/local-replica.md +78 -0
- package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
- 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 +5 -3
- package/skills/catalyst-linear/references/local-replica.md +91 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +6 -4
- package/skills/catalyst-onboard/references/local-replica.md +40 -0
- package/skills/catalyst-onboard/references/local-sync.md +8 -4
- package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -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 +4 -2
- package/skills/whats-happening/references/local-replica.md +51 -0
- package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
- 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,226 @@
|
|
|
1
|
+
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
2
|
+
import { verifyOnboardRoutes } from "./onboard-capabilities.js";
|
|
3
|
+
import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
4
|
+
import { readOnboardRepositoryInventory, selectedOnboardRepositories, } from "./onboard-repositories.js";
|
|
5
|
+
const route = "/api/v1/me/runner-capacity";
|
|
6
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
7
|
+
? value
|
|
8
|
+
: null;
|
|
9
|
+
const integer = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
10
|
+
const text = (value) => typeof value === "string" &&
|
|
11
|
+
value.length > 0 &&
|
|
12
|
+
value.length <= 256 &&
|
|
13
|
+
!Array.from(value).some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
|
|
14
|
+
const waiting = (reason) => ({
|
|
15
|
+
state: "waiting",
|
|
16
|
+
reason,
|
|
17
|
+
});
|
|
18
|
+
function snapshot(value, now) {
|
|
19
|
+
const body = object(value);
|
|
20
|
+
if (!body ||
|
|
21
|
+
body.status !== "ok" ||
|
|
22
|
+
body.scope !== "mapped-team-defaults" ||
|
|
23
|
+
!integer(body.observedAtMs) ||
|
|
24
|
+
body.observedAtMs < now - 30_000 ||
|
|
25
|
+
body.observedAtMs > now + 5_000 ||
|
|
26
|
+
!Array.isArray(body.buckets) ||
|
|
27
|
+
body.buckets.length > 500)
|
|
28
|
+
return null;
|
|
29
|
+
const buckets = [], seen = new Set(), seenTeam = new Set();
|
|
30
|
+
let totalTeams = 0;
|
|
31
|
+
for (const value of body.buckets) {
|
|
32
|
+
const b = object(value);
|
|
33
|
+
if (!b ||
|
|
34
|
+
!text(b.repoId) ||
|
|
35
|
+
seen.has(b.repoId) ||
|
|
36
|
+
!integer(b.effectiveLimit) ||
|
|
37
|
+
!integer(b.occupiedUnits) ||
|
|
38
|
+
!Array.isArray(b.teams) ||
|
|
39
|
+
!b.teams.length ||
|
|
40
|
+
b.teams.length > 500)
|
|
41
|
+
return null;
|
|
42
|
+
seen.add(b.repoId);
|
|
43
|
+
totalTeams += b.teams.length;
|
|
44
|
+
if (totalTeams > 1000)
|
|
45
|
+
return null;
|
|
46
|
+
const teams = [];
|
|
47
|
+
let minimum = Number.MAX_SAFE_INTEGER;
|
|
48
|
+
for (const value of b.teams) {
|
|
49
|
+
const t = object(value);
|
|
50
|
+
if (!t ||
|
|
51
|
+
!text(t.teamKey) ||
|
|
52
|
+
seenTeam.has(t.teamKey) ||
|
|
53
|
+
!integer(t.defaultLimit) ||
|
|
54
|
+
t.defaultLimit < 1 ||
|
|
55
|
+
!(t.configuredLimit === null ||
|
|
56
|
+
(integer(t.configuredLimit) && t.configuredLimit > 0)) ||
|
|
57
|
+
typeof t.paused !== "boolean" ||
|
|
58
|
+
typeof t.admissionEnabled !== "boolean" ||
|
|
59
|
+
!integer(t.effectiveTeamLimit))
|
|
60
|
+
return null;
|
|
61
|
+
const configured = t.configuredLimit === null ? t.defaultLimit : t.configuredLimit;
|
|
62
|
+
if (t.source !== (t.configuredLimit === null ? "default" : "configured") ||
|
|
63
|
+
t.effectiveTeamLimit !== (t.paused ? 0 : configured))
|
|
64
|
+
return null;
|
|
65
|
+
const remaining = t.admissionEnabled
|
|
66
|
+
? Math.max(0, b.effectiveLimit - b.occupiedUnits)
|
|
67
|
+
: null;
|
|
68
|
+
if (t.remainingUnits !== remaining)
|
|
69
|
+
return null;
|
|
70
|
+
seenTeam.add(t.teamKey);
|
|
71
|
+
minimum = Math.min(minimum, t.effectiveTeamLimit);
|
|
72
|
+
teams.push({ key: t.teamKey, admission: t.admissionEnabled, remaining });
|
|
73
|
+
}
|
|
74
|
+
if (minimum !== b.effectiveLimit)
|
|
75
|
+
return null;
|
|
76
|
+
buckets.push({
|
|
77
|
+
repo: b.repoId,
|
|
78
|
+
limit: b.effectiveLimit,
|
|
79
|
+
occupied: b.occupiedUnits,
|
|
80
|
+
teams,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return { at: body.observedAtMs, buckets };
|
|
84
|
+
}
|
|
85
|
+
function binding(ctx, journal) {
|
|
86
|
+
const cfg = loadConfig(ctx.home);
|
|
87
|
+
if (!cfg?.user ||
|
|
88
|
+
!["owner", "admin"].includes(cfg.user.role) ||
|
|
89
|
+
cfg.account !== (journal.account ?? journal.tenant) ||
|
|
90
|
+
cfg.user.id !== journal.membershipId ||
|
|
91
|
+
!journal.baseUrl ||
|
|
92
|
+
normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
|
|
93
|
+
return null;
|
|
94
|
+
return JSON.stringify([
|
|
95
|
+
cfg.account,
|
|
96
|
+
cfg.user.id,
|
|
97
|
+
cfg.user.role,
|
|
98
|
+
normalizeBaseUrl(cfg.baseUrl),
|
|
99
|
+
cfg.permissions,
|
|
100
|
+
cfg.key ?? cfg.auth?.sessionId,
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
const selection = (journal) => JSON.stringify([
|
|
104
|
+
selectedOnboardTeam(journal),
|
|
105
|
+
selectedOnboardRepositories(journal),
|
|
106
|
+
]);
|
|
107
|
+
async function liveTeamKey(ctx, teamId, signal) {
|
|
108
|
+
const read = await readExistingOnboardJson(ctx, "/api/v1/agent/teams", signal);
|
|
109
|
+
if ("reason" in read)
|
|
110
|
+
return null;
|
|
111
|
+
const body = object(read.body), live = object(body?.liveTeamRead);
|
|
112
|
+
if (!body ||
|
|
113
|
+
!Array.isArray(body.teams) ||
|
|
114
|
+
body.teams.length > 1000 ||
|
|
115
|
+
(live?.error !== undefined && live.error !== null))
|
|
116
|
+
return null;
|
|
117
|
+
const ids = new Set(), keys = new Set();
|
|
118
|
+
let selected = null;
|
|
119
|
+
for (const value of body.teams) {
|
|
120
|
+
const team = object(value);
|
|
121
|
+
if (!team ||
|
|
122
|
+
!text(team.teamId) ||
|
|
123
|
+
!text(team.teamKey) ||
|
|
124
|
+
!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(team.teamKey) ||
|
|
125
|
+
ids.has(team.teamId) ||
|
|
126
|
+
keys.has(team.teamKey))
|
|
127
|
+
return null;
|
|
128
|
+
ids.add(team.teamId);
|
|
129
|
+
keys.add(team.teamKey);
|
|
130
|
+
if (team.teamId === teamId)
|
|
131
|
+
selected = team.teamKey;
|
|
132
|
+
}
|
|
133
|
+
return selected;
|
|
134
|
+
}
|
|
135
|
+
async function liveIdentity(ctx, journal, signal) {
|
|
136
|
+
const read = await readExistingOnboardJson(ctx, "/api/v1/me", signal);
|
|
137
|
+
if ("reason" in read)
|
|
138
|
+
return false;
|
|
139
|
+
const me = object(read.body), user = object(me?.user), cfg = loadConfig(ctx.home);
|
|
140
|
+
return (!!cfg?.user &&
|
|
141
|
+
me?.account === (journal.account ?? journal.tenant) &&
|
|
142
|
+
user?.id === cfg.user.id &&
|
|
143
|
+
user.role === cfg.user.role &&
|
|
144
|
+
["owner", "admin"].includes(cfg.user.role) &&
|
|
145
|
+
me?.principal === cfg.principal);
|
|
146
|
+
}
|
|
147
|
+
/** Fresh advisory for the selected team's DEFAULT mapped repo. It grants no lease and does not
|
|
148
|
+
* establish provider eligibility, host liveness or selected-repo overrides. No extra question. */
|
|
149
|
+
export function onboardCapacityAdapter(input = {}) {
|
|
150
|
+
return {
|
|
151
|
+
check: async (ctx, journal, external) => {
|
|
152
|
+
if (external?.aborted)
|
|
153
|
+
return waiting("interrupted");
|
|
154
|
+
const expected = binding(ctx, journal), selected = selection(journal), teamId = selectedOnboardTeam(journal), repos = selectedOnboardRepositories(journal);
|
|
155
|
+
if (!expected)
|
|
156
|
+
return waiting("capacity_identity_unverified");
|
|
157
|
+
if (!teamId || repos.length !== 1 || repos[0].teamId !== teamId)
|
|
158
|
+
return waiting("capacity_context_unverified");
|
|
159
|
+
const controller = new AbortController(), signal = external
|
|
160
|
+
? AbortSignal.any([external, controller.signal])
|
|
161
|
+
: controller.signal;
|
|
162
|
+
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
163
|
+
const current = () => !signal.aborted &&
|
|
164
|
+
binding(ctx, journal) === expected &&
|
|
165
|
+
selection(journal) === selected;
|
|
166
|
+
const pause = (reason) => waiting(external?.aborted
|
|
167
|
+
? "interrupted"
|
|
168
|
+
: controller.signal.aborted
|
|
169
|
+
? "capacity_unavailable"
|
|
170
|
+
: reason);
|
|
171
|
+
try {
|
|
172
|
+
const support = await verifyOnboardRoutes(ctx, journal, [{ method: "GET", path: route }], signal);
|
|
173
|
+
if ("reason" in support)
|
|
174
|
+
return pause(support.reason);
|
|
175
|
+
if (!current() ||
|
|
176
|
+
!(await liveIdentity(ctx, journal, signal)) ||
|
|
177
|
+
!current())
|
|
178
|
+
return pause("capacity_identity_unverified");
|
|
179
|
+
const teamKey = await liveTeamKey(ctx, teamId, signal);
|
|
180
|
+
if (!teamKey || !current())
|
|
181
|
+
return pause("capacity_context_unverified");
|
|
182
|
+
const inventory = await readOnboardRepositoryInventory(ctx, journal, signal);
|
|
183
|
+
if ("reason" in inventory ||
|
|
184
|
+
!current() ||
|
|
185
|
+
!inventory.repositories.some((r) => r.repoId === repos[0].repoId &&
|
|
186
|
+
r.teamId === teamId &&
|
|
187
|
+
r.owner.toLowerCase() === repos[0].owner.toLowerCase() &&
|
|
188
|
+
r.name.toLowerCase() === repos[0].name.toLowerCase()))
|
|
189
|
+
return pause("capacity_context_unverified");
|
|
190
|
+
const read = await readExistingOnboardJson(ctx, route, signal);
|
|
191
|
+
if ("reason" in read || !current())
|
|
192
|
+
return pause("capacity_unavailable");
|
|
193
|
+
if (!(await liveIdentity(ctx, journal, signal)) || !current())
|
|
194
|
+
return pause("capacity_identity_unverified");
|
|
195
|
+
const observed = snapshot(read.body, ctx.now().getTime());
|
|
196
|
+
const bucket = observed?.buckets.find((b) => b.repo === repos[0].repoId), mapped = bucket?.teams.find((t) => t.key === teamKey);
|
|
197
|
+
if (!observed || !bucket || !mapped)
|
|
198
|
+
return pause("capacity_context_unverified");
|
|
199
|
+
if (!mapped.admission || mapped.remaining === null)
|
|
200
|
+
return pause("capacity_admission_unverified");
|
|
201
|
+
if (mapped.remaining === 0)
|
|
202
|
+
return pause("capacity_currently_full");
|
|
203
|
+
input.message?.(`Runner capacity observed: ${bucket.occupied} of ${bucket.limit} units in use. Starting work will check admission again.`);
|
|
204
|
+
if (!current())
|
|
205
|
+
return pause("capacity_identity_unverified");
|
|
206
|
+
return {
|
|
207
|
+
state: "done",
|
|
208
|
+
evidence: {
|
|
209
|
+
repoId: bucket.repo,
|
|
210
|
+
effectiveLimit: bucket.limit,
|
|
211
|
+
occupiedUnits: bucket.occupied,
|
|
212
|
+
remainingUnits: mapped.remaining,
|
|
213
|
+
observedAtMs: observed.at,
|
|
214
|
+
advisory: true,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return pause(external?.aborted ? "interrupted" : "capacity_unavailable");
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
package/dist/onboard-existing.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
2
2
|
import { CliError } from "./errors.js";
|
|
3
3
|
const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
4
|
-
const waiting = (reason) => ({
|
|
5
|
-
|
|
4
|
+
const waiting = (reason) => ({
|
|
5
|
+
state: "waiting",
|
|
6
|
+
reason,
|
|
7
|
+
});
|
|
8
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
9
|
+
? value
|
|
10
|
+
: null;
|
|
6
11
|
const label = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 120);
|
|
7
12
|
/** A receipt keeps an explicit selection, never the team used to inspect the tenant grant. */
|
|
8
13
|
export function selectedOnboardTeam(journal) {
|
|
9
|
-
const step = journal?.steps.find(row => row.id === "linear.team");
|
|
14
|
+
const step = journal?.steps.find((row) => row.id === "linear.team");
|
|
10
15
|
const id = step?.state === "done" ? step.evidence?.team : undefined;
|
|
11
16
|
return typeof id === "string" && idPattern.test(id) ? id : undefined;
|
|
12
17
|
}
|
|
@@ -15,15 +20,23 @@ export async function readExistingOnboardJson(ctx, path, external) {
|
|
|
15
20
|
const cfg = loadConfig(ctx.home);
|
|
16
21
|
if (!cfg?.user)
|
|
17
22
|
return { reason: "personal_login_required" };
|
|
23
|
+
const personId = cfg.user.id;
|
|
18
24
|
// Identity verification refreshes before adapters run. These bounded reads never rotate a grant
|
|
19
25
|
// that could finish after the engine has released its lock.
|
|
20
|
-
const expiry = cfg.auth
|
|
21
|
-
|
|
26
|
+
const expiry = cfg.auth
|
|
27
|
+
? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
|
|
28
|
+
: 0;
|
|
29
|
+
const bearer = cfg.key ||
|
|
30
|
+
(cfg.auth && Number.isFinite(expiry) && expiry > 30_000
|
|
31
|
+
? cfg.auth.accessToken
|
|
32
|
+
: undefined);
|
|
22
33
|
if (!bearer)
|
|
23
34
|
return { reason: "team_read_login_refresh_required" };
|
|
24
35
|
const deadline = new AbortController();
|
|
25
36
|
const timer = setTimeout(() => deadline.abort(), 30_000);
|
|
26
|
-
const signal = external
|
|
37
|
+
const signal = external
|
|
38
|
+
? AbortSignal.any([external, deadline.signal])
|
|
39
|
+
: deadline.signal;
|
|
27
40
|
let remove = () => { };
|
|
28
41
|
try {
|
|
29
42
|
return await new Promise((resolve, reject) => {
|
|
@@ -36,17 +49,32 @@ export async function readExistingOnboardJson(ctx, path, external) {
|
|
|
36
49
|
}
|
|
37
50
|
signal.addEventListener("abort", stopped, { once: true });
|
|
38
51
|
remove = () => signal.removeEventListener("abort", stopped);
|
|
39
|
-
Promise.resolve()
|
|
52
|
+
Promise.resolve()
|
|
53
|
+
.then(async () => {
|
|
40
54
|
if (signal.aborted)
|
|
41
55
|
return { reason: "team_read_unavailable" };
|
|
42
56
|
const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
|
|
43
|
-
|
|
57
|
+
redirect: "error",
|
|
58
|
+
headers: {
|
|
59
|
+
authorization: `Bearer ${bearer}`,
|
|
60
|
+
accept: "application/json",
|
|
61
|
+
},
|
|
62
|
+
signal,
|
|
44
63
|
});
|
|
45
64
|
if (response.status !== 200)
|
|
46
65
|
return { reason: "team_read_unavailable" };
|
|
47
66
|
const body = await response.json();
|
|
67
|
+
const current = loadConfig(ctx.home);
|
|
68
|
+
if (!current?.user ||
|
|
69
|
+
current.account !== cfg.account ||
|
|
70
|
+
current.user.id !== personId ||
|
|
71
|
+
normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
|
|
72
|
+
return { reason: "team_read_identity_unverified" };
|
|
48
73
|
return { body };
|
|
49
|
-
})
|
|
74
|
+
})
|
|
75
|
+
.then((value) => (signal.aborted ? stopped() : resolve(value)), () => signal.aborted
|
|
76
|
+
? stopped()
|
|
77
|
+
: resolve({ reason: "team_read_unavailable" }));
|
|
50
78
|
});
|
|
51
79
|
}
|
|
52
80
|
finally {
|
|
@@ -54,7 +82,7 @@ export async function readExistingOnboardJson(ctx, path, external) {
|
|
|
54
82
|
remove();
|
|
55
83
|
}
|
|
56
84
|
}
|
|
57
|
-
async function
|
|
85
|
+
export async function readOnboardTeamInventory(ctx, signal) {
|
|
58
86
|
const read = await readExistingOnboardJson(ctx, "/api/v1/agent/teams", signal);
|
|
59
87
|
if ("reason" in read)
|
|
60
88
|
return read;
|
|
@@ -68,11 +96,19 @@ async function inventory(ctx, signal) {
|
|
|
68
96
|
const seen = new Set();
|
|
69
97
|
for (const value of body.teams) {
|
|
70
98
|
const row = object(value);
|
|
71
|
-
if (!row ||
|
|
72
|
-
typeof row.
|
|
99
|
+
if (!row ||
|
|
100
|
+
typeof row.teamId !== "string" ||
|
|
101
|
+
!idPattern.test(row.teamId) ||
|
|
102
|
+
seen.has(row.teamId) ||
|
|
103
|
+
typeof row.teamKey !== "string" ||
|
|
104
|
+
typeof row.teamName !== "string")
|
|
73
105
|
return { reason: "team_inventory_shape" };
|
|
74
106
|
seen.add(row.teamId);
|
|
75
|
-
teams.push({
|
|
107
|
+
teams.push({
|
|
108
|
+
id: row.teamId,
|
|
109
|
+
key: label(row.teamKey),
|
|
110
|
+
name: label(row.teamName),
|
|
111
|
+
});
|
|
76
112
|
}
|
|
77
113
|
return { teams };
|
|
78
114
|
}
|
|
@@ -82,14 +118,27 @@ async function observe(ctx, id, signal) {
|
|
|
82
118
|
return null;
|
|
83
119
|
const row = object(object(read.body)?.readiness);
|
|
84
120
|
// The GET owns expiry/revision freshness. Do not invent an expiresAt absent from its wire shape.
|
|
85
|
-
if (!row ||
|
|
86
|
-
|
|
87
|
-
typeof row.
|
|
121
|
+
if (!row ||
|
|
122
|
+
row.teamId !== id ||
|
|
123
|
+
typeof row.status !== "string" ||
|
|
124
|
+
!["ready", "degraded", "blocked"].includes(row.status) ||
|
|
125
|
+
typeof row.checkedAt !== "number" ||
|
|
126
|
+
!Number.isFinite(row.checkedAt) ||
|
|
127
|
+
row.checkedAt < 0 ||
|
|
128
|
+
row.checkedAt > ctx.now().getTime() ||
|
|
129
|
+
typeof row.workflowRev !== "number" ||
|
|
130
|
+
!Number.isSafeInteger(row.workflowRev) ||
|
|
131
|
+
row.workflowRev < 0 ||
|
|
132
|
+
!Array.isArray(row.checks))
|
|
88
133
|
return null;
|
|
89
134
|
const checks = new Map();
|
|
90
135
|
for (const value of row.checks) {
|
|
91
136
|
const check = object(value);
|
|
92
|
-
if (!check ||
|
|
137
|
+
if (!check ||
|
|
138
|
+
typeof check.id !== "string" ||
|
|
139
|
+
checks.has(check.id) ||
|
|
140
|
+
typeof check.state !== "string" ||
|
|
141
|
+
!["pass", "fail", "unknown"].includes(check.state))
|
|
93
142
|
return null;
|
|
94
143
|
checks.set(check.id, check.state);
|
|
95
144
|
}
|
|
@@ -100,17 +149,17 @@ export function existingLinearAdapters(args, choose) {
|
|
|
100
149
|
const requested = (teams, journal) => {
|
|
101
150
|
const explicit = args.flags.team;
|
|
102
151
|
if (typeof explicit === "string") {
|
|
103
|
-
const ids = teams.filter(team => team.id === explicit);
|
|
152
|
+
const ids = teams.filter((team) => team.id === explicit);
|
|
104
153
|
if (ids.length === 1)
|
|
105
154
|
return ids[0];
|
|
106
|
-
const keys = teams.filter(team => team.key === explicit);
|
|
155
|
+
const keys = teams.filter((team) => team.key === explicit);
|
|
107
156
|
return keys.length === 1 ? keys[0] : undefined;
|
|
108
157
|
}
|
|
109
158
|
const saved = chosen ?? selectedOnboardTeam(journal);
|
|
110
|
-
return saved ? teams.find(team => team.id === saved) : undefined;
|
|
159
|
+
return saved ? teams.find((team) => team.id === saved) : undefined;
|
|
111
160
|
};
|
|
112
161
|
const teamCheck = async (ctx, journal, signal) => {
|
|
113
|
-
const list = await
|
|
162
|
+
const list = await readOnboardTeamInventory(ctx, signal);
|
|
114
163
|
if ("reason" in list)
|
|
115
164
|
return waiting(list.reason);
|
|
116
165
|
if (!list.teams.length)
|
|
@@ -124,23 +173,43 @@ export function existingLinearAdapters(args, choose) {
|
|
|
124
173
|
const result = await observe(ctx, team.id, signal);
|
|
125
174
|
if (!result || result.checks.get("team_visible") !== "pass")
|
|
126
175
|
return waiting("team_selection_unverified");
|
|
127
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
state: "done",
|
|
178
|
+
evidence: {
|
|
179
|
+
team: team.id,
|
|
180
|
+
teamKey: team.key,
|
|
181
|
+
checkedAt: result.checkedAt,
|
|
182
|
+
revision: result.revision,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
128
185
|
};
|
|
129
186
|
return {
|
|
130
|
-
"linear.workspace": {
|
|
131
|
-
|
|
187
|
+
"linear.workspace": {
|
|
188
|
+
check: async (ctx, journal, signal) => {
|
|
189
|
+
const list = await readOnboardTeamInventory(ctx, signal);
|
|
132
190
|
if ("reason" in list)
|
|
133
191
|
return waiting(list.reason);
|
|
134
192
|
const candidate = requested(list.teams, journal) ?? list.teams[0];
|
|
135
193
|
if (!candidate)
|
|
136
194
|
return waiting("linear_workspace_unverified");
|
|
137
195
|
const result = await observe(ctx, candidate.id, signal);
|
|
138
|
-
return result?.checks.get("oauth_scope") === "pass" &&
|
|
139
|
-
|
|
196
|
+
return result?.checks.get("oauth_scope") === "pass" &&
|
|
197
|
+
result.checks.get("token_live") === "pass"
|
|
198
|
+
? {
|
|
199
|
+
state: "done",
|
|
200
|
+
evidence: {
|
|
201
|
+
checkedAt: result.checkedAt,
|
|
202
|
+
revision: result.revision,
|
|
203
|
+
},
|
|
204
|
+
}
|
|
140
205
|
: waiting("linear_workspace_unverified");
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
|
|
206
|
+
},
|
|
207
|
+
act: async () => waiting("cloud_capability_unavailable"),
|
|
208
|
+
},
|
|
209
|
+
"linear.team": {
|
|
210
|
+
check: teamCheck,
|
|
211
|
+
act: async (ctx, journal, signal) => {
|
|
212
|
+
const list = await readOnboardTeamInventory(ctx, signal);
|
|
144
213
|
if ("reason" in list)
|
|
145
214
|
return waiting(list.reason);
|
|
146
215
|
if (!list.teams.length)
|
|
@@ -156,14 +225,19 @@ export function existingLinearAdapters(args, choose) {
|
|
|
156
225
|
}
|
|
157
226
|
signal?.addEventListener("abort", stopped, { once: true });
|
|
158
227
|
remove = () => signal?.removeEventListener("abort", stopped);
|
|
159
|
-
Promise.resolve()
|
|
228
|
+
Promise.resolve()
|
|
229
|
+
.then(() => signal?.aborted
|
|
230
|
+
? null
|
|
231
|
+
: choose(list.teams.map((team) => ({ ...team }))))
|
|
232
|
+
.then(resolve, reject);
|
|
160
233
|
}).finally(() => remove());
|
|
161
234
|
if (!answer || signal?.aborted)
|
|
162
235
|
return waiting("interrupted");
|
|
163
|
-
if (!list.teams.some(team => team.id === answer))
|
|
236
|
+
if (!list.teams.some((team) => team.id === answer))
|
|
164
237
|
return waiting("team_selection_unverified");
|
|
165
238
|
chosen = answer;
|
|
166
239
|
return teamCheck(ctx, journal, signal);
|
|
167
|
-
}
|
|
240
|
+
},
|
|
241
|
+
},
|
|
168
242
|
};
|
|
169
243
|
}
|
|
@@ -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
|
+
}
|
package/dist/onboard-github.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finishOnTheWeb } from "./consent-browser.js";
|
|
1
2
|
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
2
3
|
import { pollConsent } from "./onboard-consent.js";
|
|
3
4
|
const STATUS = "/api/v1/me/connections/github/workspace";
|
|
@@ -292,11 +293,15 @@ export function githubInstallationAdapter(options) {
|
|
|
292
293
|
};
|
|
293
294
|
ctx.stderr("Approve the GitHub App installation in your browser. Your personal GitHub connection is checked separately.");
|
|
294
295
|
// The signed URL is transient credential material: only the browser receives it.
|
|
296
|
+
let browserUnavailable = false;
|
|
295
297
|
try {
|
|
296
|
-
options.openBrowser(url);
|
|
298
|
+
await options.openBrowser(url, signal);
|
|
297
299
|
}
|
|
298
300
|
catch {
|
|
299
|
-
|
|
301
|
+
if (signal?.aborted)
|
|
302
|
+
return waiting("interrupted");
|
|
303
|
+
browserUnavailable = true;
|
|
304
|
+
ctx.stderr(finishOnTheWeb(current.baseUrl, "connections", "install the GitHub App"));
|
|
300
305
|
}
|
|
301
306
|
let latest = waiting("github_installation_status_unavailable");
|
|
302
307
|
const run = () => pollConsent({
|
|
@@ -311,6 +316,8 @@ export function githubInstallationAdapter(options) {
|
|
|
311
316
|
outcome: "waiting",
|
|
312
317
|
reason: "cloud_capability_unavailable",
|
|
313
318
|
};
|
|
319
|
+
if (latest.reason === "github_installation_login_refresh_required")
|
|
320
|
+
return { outcome: "waiting", reason: latest.reason };
|
|
314
321
|
if (latest.state === "done")
|
|
315
322
|
return { outcome: "connected" };
|
|
316
323
|
if (latest.state === "refused" || latest.state === "failed")
|
|
@@ -323,7 +330,11 @@ export function githubInstallationAdapter(options) {
|
|
|
323
330
|
const result = options.wait
|
|
324
331
|
? await options.wait("Waiting for GitHub App approval", run)
|
|
325
332
|
: await run();
|
|
326
|
-
|
|
333
|
+
if (result.state === "done")
|
|
334
|
+
return latest;
|
|
335
|
+
return browserUnavailable && result.reason === "consent_timeout"
|
|
336
|
+
? waiting("github_installation_browser_unavailable")
|
|
337
|
+
: result;
|
|
327
338
|
},
|
|
328
339
|
};
|
|
329
340
|
}
|