@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,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
|
+
}
|
|
@@ -1,44 +1,105 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isRepositoryId } from "./repository-id.js";
|
|
2
|
+
import { loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
|
|
2
3
|
import { contractVersionInRange } from "./contract.js";
|
|
3
|
-
import { readExistingOnboardJson, selectedOnboardTeam } from "./onboard-existing.js";
|
|
4
|
+
import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
4
5
|
const part = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
|
5
6
|
const id = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
6
|
-
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
7
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: null;
|
|
7
10
|
const key = (row) => `${row.owner}/${row.name}`.toLowerCase();
|
|
8
|
-
const waiting = (reason) => ({
|
|
11
|
+
const waiting = (reason) => ({
|
|
12
|
+
state: "waiting",
|
|
13
|
+
reason,
|
|
14
|
+
});
|
|
9
15
|
const valid = (value) => {
|
|
10
16
|
const row = object(value);
|
|
11
|
-
return !!row &&
|
|
12
|
-
typeof row.
|
|
17
|
+
return (!!row &&
|
|
18
|
+
typeof row.teamId === "string" &&
|
|
19
|
+
id.test(row.teamId) &&
|
|
20
|
+
isRepositoryId(row.repoId) &&
|
|
21
|
+
typeof row.owner === "string" &&
|
|
22
|
+
part.test(row.owner) &&
|
|
23
|
+
typeof row.name === "string" &&
|
|
24
|
+
part.test(row.name));
|
|
13
25
|
};
|
|
14
26
|
/** Names/IDs only. A saved selection never proves current access or repository readiness. */
|
|
15
27
|
export function selectedOnboardRepositories(journal) {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
28
|
+
const selection = journal?.steps.find((row) => row.id === "github.repos" && row.state === "done");
|
|
29
|
+
const registered = journal?.steps.find((row) => row.id === "projects" && row.state === "done");
|
|
30
|
+
const receipt = selection?.evidence?.repository;
|
|
18
31
|
if (typeof receipt !== "string" || receipt.length > 64_000)
|
|
19
32
|
return [];
|
|
20
33
|
try {
|
|
21
34
|
const rows = JSON.parse(receipt);
|
|
22
|
-
if (!Array.isArray(rows) || !rows.length || rows.length > 100
|
|
35
|
+
if (!Array.isArray(rows) || !rows.length || rows.length > 100)
|
|
23
36
|
return [];
|
|
24
|
-
if (
|
|
37
|
+
if (rows.every(valid)) {
|
|
38
|
+
if (new Set(rows.map(key)).size !== rows.length)
|
|
39
|
+
return [];
|
|
40
|
+
return rows.map((row) => ({
|
|
41
|
+
teamId: row.teamId,
|
|
42
|
+
owner: row.owner,
|
|
43
|
+
name: row.name,
|
|
44
|
+
repoId: row.repoId,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
// A fresh Q3 choice has names only. It may use later verified registration evidence only
|
|
48
|
+
// when the exact selected team/repository matches, never an earlier project's selection.
|
|
49
|
+
if (rows.length !== 1)
|
|
50
|
+
return [];
|
|
51
|
+
const selected = object(rows[0]);
|
|
52
|
+
if (!selected ||
|
|
53
|
+
selected.repoId !== undefined ||
|
|
54
|
+
typeof selected.teamId !== "string" ||
|
|
55
|
+
!id.test(selected.teamId) ||
|
|
56
|
+
typeof selected.owner !== "string" ||
|
|
57
|
+
!part.test(selected.owner) ||
|
|
58
|
+
typeof selected.name !== "string" ||
|
|
59
|
+
!part.test(selected.name))
|
|
60
|
+
return [];
|
|
61
|
+
const evidence = registered?.evidence?.repository;
|
|
62
|
+
if (typeof evidence !== "string" || evidence.length > 64_000)
|
|
25
63
|
return [];
|
|
26
|
-
|
|
64
|
+
const registeredRows = JSON.parse(evidence);
|
|
65
|
+
if (!Array.isArray(registeredRows) ||
|
|
66
|
+
registeredRows.length !== 1 ||
|
|
67
|
+
!registeredRows.every(valid))
|
|
68
|
+
return [];
|
|
69
|
+
const live = registeredRows[0];
|
|
70
|
+
return live.teamId === selected.teamId &&
|
|
71
|
+
key(live) === key({ owner: selected.owner, name: selected.name })
|
|
72
|
+
? [{ ...live }]
|
|
73
|
+
: [];
|
|
27
74
|
}
|
|
28
75
|
catch {
|
|
29
76
|
return [];
|
|
30
77
|
}
|
|
31
78
|
}
|
|
32
79
|
/** The personal ACL inventory chooses bindings; a fresh contract supplies IDs only. No cache read. */
|
|
33
|
-
async function
|
|
80
|
+
export async function readOnboardRepositoryInventory(ctx, journal, signal) {
|
|
34
81
|
const cfg = loadConfig(ctx.home);
|
|
35
|
-
if (!cfg?.user ||
|
|
36
|
-
!journal.
|
|
82
|
+
if (!cfg?.user ||
|
|
83
|
+
!journal.account ||
|
|
84
|
+
cfg.account !== journal.account ||
|
|
85
|
+
journal.membershipId !== cfg.user.id ||
|
|
86
|
+
!journal.baseUrl ||
|
|
87
|
+
normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
|
|
37
88
|
return { reason: "repository_identity_unverified" };
|
|
89
|
+
const personId = cfg.user.id;
|
|
90
|
+
const sameIdentity = () => {
|
|
91
|
+
const current = loadConfig(ctx.home);
|
|
92
|
+
return (current?.user &&
|
|
93
|
+
current.account === cfg.account &&
|
|
94
|
+
current.user.id === personId &&
|
|
95
|
+
normalizeBaseUrl(current.baseUrl) === normalizeBaseUrl(cfg.baseUrl));
|
|
96
|
+
};
|
|
38
97
|
const teamId = selectedOnboardTeam(journal);
|
|
39
98
|
if (!teamId)
|
|
40
99
|
return { reason: "team_selection_unverified" };
|
|
41
100
|
const read = await readExistingOnboardJson(ctx, "/api/v1/repos", signal);
|
|
101
|
+
if (!sameIdentity())
|
|
102
|
+
return { reason: "repository_identity_unverified" };
|
|
42
103
|
if ("reason" in read)
|
|
43
104
|
return { reason: "repository_read_unavailable" };
|
|
44
105
|
const body = object(read.body);
|
|
@@ -48,8 +109,13 @@ async function inventory(ctx, journal, signal) {
|
|
|
48
109
|
const seen = new Set();
|
|
49
110
|
for (const value of body.repos) {
|
|
50
111
|
const row = object(value);
|
|
51
|
-
if (!row ||
|
|
52
|
-
typeof row.
|
|
112
|
+
if (!row ||
|
|
113
|
+
typeof row.teamId !== "string" ||
|
|
114
|
+
!id.test(row.teamId) ||
|
|
115
|
+
typeof row.owner !== "string" ||
|
|
116
|
+
!part.test(row.owner) ||
|
|
117
|
+
typeof row.name !== "string" ||
|
|
118
|
+
!part.test(row.name))
|
|
53
119
|
return { reason: "repository_inventory_shape" };
|
|
54
120
|
if (row.teamId !== teamId)
|
|
55
121
|
continue;
|
|
@@ -59,24 +125,36 @@ async function inventory(ctx, journal, signal) {
|
|
|
59
125
|
seen.add(name);
|
|
60
126
|
bindings.push({ owner: row.owner, name: row.name });
|
|
61
127
|
}
|
|
128
|
+
if (!sameIdentity())
|
|
129
|
+
return { reason: "repository_identity_unverified" };
|
|
62
130
|
if (!bindings.length)
|
|
63
131
|
return { repositories: [] };
|
|
64
132
|
const contractRead = await readExistingOnboardJson(ctx, "/api/v1/agent/contract", signal);
|
|
133
|
+
if (!sameIdentity())
|
|
134
|
+
return { reason: "repository_identity_unverified" };
|
|
65
135
|
if ("reason" in contractRead)
|
|
66
136
|
return { reason: "repository_contract_unavailable" };
|
|
67
137
|
const doc = object(contractRead.body);
|
|
68
138
|
const account = object(doc?.account);
|
|
69
139
|
const merge = object(doc?.merge);
|
|
70
|
-
if (!doc ||
|
|
140
|
+
if (!doc ||
|
|
141
|
+
account?.id !== journal.account ||
|
|
142
|
+
typeof doc.contractVersion !== "string" ||
|
|
71
143
|
contractVersionInRange(doc.contractVersion, readManifest().tenantContractRange) !== true ||
|
|
72
|
-
!merge ||
|
|
144
|
+
!merge ||
|
|
145
|
+
!Array.isArray(merge.repositories) ||
|
|
146
|
+
merge.repositories.length > 10_000)
|
|
73
147
|
return { reason: "repository_contract_unverified" };
|
|
74
148
|
const lookup = new Map();
|
|
75
149
|
const idNames = new Map();
|
|
76
150
|
for (const value of merge.repositories) {
|
|
77
151
|
const row = object(value);
|
|
78
|
-
if (!row ||
|
|
79
|
-
typeof row.
|
|
152
|
+
if (!row ||
|
|
153
|
+
typeof row.owner !== "string" ||
|
|
154
|
+
!part.test(row.owner) ||
|
|
155
|
+
typeof row.name !== "string" ||
|
|
156
|
+
!part.test(row.name) ||
|
|
157
|
+
!isRepositoryId(row.repoId))
|
|
80
158
|
return { reason: "repository_contract_unverified" };
|
|
81
159
|
const name = key({ owner: row.owner, name: row.name });
|
|
82
160
|
if (idNames.has(row.repoId) && idNames.get(row.repoId) !== name)
|
|
@@ -99,34 +177,57 @@ export function existingRepositoryAdapter(args, choose) {
|
|
|
99
177
|
const explicit = args.flags.repo;
|
|
100
178
|
if (explicit !== undefined) {
|
|
101
179
|
const names = Array.isArray(explicit) ? explicit : [explicit];
|
|
102
|
-
if (!names.length ||
|
|
180
|
+
if (!names.length ||
|
|
181
|
+
names.length > 100 ||
|
|
182
|
+
!names.every((name) => typeof name === "string" &&
|
|
183
|
+
/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(name)))
|
|
103
184
|
return null;
|
|
104
|
-
const wanted = names.map(name => String(name).toLowerCase());
|
|
185
|
+
const wanted = names.map((name) => String(name).toLowerCase());
|
|
105
186
|
if (new Set(wanted).size !== wanted.length)
|
|
106
187
|
return null;
|
|
107
|
-
const selected = wanted.map(name => repositories.find(row => key(row) === name));
|
|
108
|
-
return selected.every((row) => !!row)
|
|
188
|
+
const selected = wanted.map((name) => repositories.find((row) => key(row) === name));
|
|
189
|
+
return selected.every((row) => !!row)
|
|
190
|
+
? selected
|
|
191
|
+
: null;
|
|
109
192
|
}
|
|
110
193
|
const saved = chosen ?? selectedOnboardRepositories(journal);
|
|
111
194
|
if (!saved.length)
|
|
112
195
|
return null;
|
|
113
|
-
const selected = saved.map(row => repositories.find(live => key(live) === key(row) &&
|
|
114
|
-
|
|
196
|
+
const selected = saved.map((row) => repositories.find((live) => key(live) === key(row) &&
|
|
197
|
+
live.teamId === row.teamId &&
|
|
198
|
+
live.repoId === row.repoId));
|
|
199
|
+
return selected.every((row) => !!row)
|
|
200
|
+
? selected
|
|
201
|
+
: null;
|
|
115
202
|
};
|
|
116
203
|
const check = async (ctx, journal, signal) => {
|
|
117
|
-
const list = await
|
|
204
|
+
const list = await readOnboardRepositoryInventory(ctx, journal, signal);
|
|
118
205
|
if ("reason" in list)
|
|
119
206
|
return waiting(list.reason);
|
|
120
207
|
if (!list.repositories.length)
|
|
121
208
|
return waiting("repository_inventory_empty");
|
|
122
209
|
const rows = requested(list.repositories, journal);
|
|
123
210
|
if (!rows)
|
|
124
|
-
return args.flags.repo !== undefined ||
|
|
125
|
-
|
|
126
|
-
|
|
211
|
+
return args.flags.repo !== undefined ||
|
|
212
|
+
chosen ||
|
|
213
|
+
selectedOnboardRepositories(journal).length
|
|
214
|
+
? waiting("repository_selection_unverified")
|
|
215
|
+
: choose
|
|
216
|
+
? { state: "pending" }
|
|
217
|
+
: waiting("repository_choice_required");
|
|
218
|
+
return {
|
|
219
|
+
state: "done",
|
|
220
|
+
evidence: {
|
|
221
|
+
repository: JSON.stringify(rows),
|
|
222
|
+
count: rows.length,
|
|
223
|
+
checkedAt: ctx.now().getTime(),
|
|
224
|
+
},
|
|
225
|
+
};
|
|
127
226
|
};
|
|
128
|
-
return {
|
|
129
|
-
|
|
227
|
+
return {
|
|
228
|
+
check,
|
|
229
|
+
act: async (ctx, journal, signal) => {
|
|
230
|
+
const list = await readOnboardRepositoryInventory(ctx, journal, signal);
|
|
130
231
|
if ("reason" in list)
|
|
131
232
|
return waiting(list.reason);
|
|
132
233
|
if (!list.repositories.length)
|
|
@@ -142,19 +243,27 @@ export function existingRepositoryAdapter(args, choose) {
|
|
|
142
243
|
}
|
|
143
244
|
signal?.addEventListener("abort", stopped, { once: true });
|
|
144
245
|
remove = () => signal?.removeEventListener("abort", stopped);
|
|
145
|
-
Promise.resolve()
|
|
246
|
+
Promise.resolve()
|
|
247
|
+
.then(() => signal?.aborted
|
|
248
|
+
? null
|
|
249
|
+
: choose(list.repositories.map((row) => ({ ...row }))))
|
|
250
|
+
.then(resolve, reject);
|
|
146
251
|
}).finally(() => remove());
|
|
147
252
|
if (!answer || signal?.aborted)
|
|
148
253
|
return waiting("interrupted");
|
|
149
|
-
if (!Array.isArray(answer) ||
|
|
254
|
+
if (!Array.isArray(answer) ||
|
|
255
|
+
!answer.length ||
|
|
256
|
+
answer.length > 100 ||
|
|
257
|
+
!answer.every((name) => typeof name === "string"))
|
|
150
258
|
return waiting("repository_selection_unverified");
|
|
151
|
-
const names = answer.map(name => name.toLowerCase());
|
|
259
|
+
const names = answer.map((name) => name.toLowerCase());
|
|
152
260
|
if (new Set(names).size !== names.length)
|
|
153
261
|
return waiting("repository_selection_unverified");
|
|
154
|
-
const rows = names.map(name => list.repositories.find(row => key(row) === name));
|
|
262
|
+
const rows = names.map((name) => list.repositories.find((row) => key(row) === name));
|
|
155
263
|
if (!rows.every((row) => !!row))
|
|
156
264
|
return waiting("repository_selection_unverified");
|
|
157
265
|
chosen = rows;
|
|
158
266
|
return check(ctx, journal, signal);
|
|
159
|
-
}
|
|
267
|
+
},
|
|
268
|
+
};
|
|
160
269
|
}
|