@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,162 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isAbsolute, normalize } from "node:path";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
function refused() {
|
|
5
|
+
throw new CliError("The staged installation plan could not be verified. Run setup again.", "onboard-bootstrap-plan", 12);
|
|
6
|
+
}
|
|
7
|
+
function record(value) {
|
|
8
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
9
|
+
return refused();
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function exactKeys(value, keys) {
|
|
13
|
+
if (Object.keys(value).length !== keys.length ||
|
|
14
|
+
keys.some((key) => !Object.hasOwn(value, key)))
|
|
15
|
+
refused();
|
|
16
|
+
}
|
|
17
|
+
function text(value) {
|
|
18
|
+
if (typeof value !== "string" ||
|
|
19
|
+
value.length === 0 ||
|
|
20
|
+
value.length > 4096 ||
|
|
21
|
+
/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(value))
|
|
22
|
+
refused();
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function path(value) {
|
|
26
|
+
const result = text(value);
|
|
27
|
+
if (!isAbsolute(result) || normalize(result) !== result || result === "/")
|
|
28
|
+
refused();
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
function origin(value) {
|
|
32
|
+
const result = text(value);
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(result);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return refused();
|
|
39
|
+
}
|
|
40
|
+
if (url.protocol !== "https:" ||
|
|
41
|
+
url.origin !== result ||
|
|
42
|
+
url.username ||
|
|
43
|
+
url.password ||
|
|
44
|
+
url.search ||
|
|
45
|
+
url.hash)
|
|
46
|
+
refused();
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
/** Copies a closed DTO before freezing it; a mutable caller object cannot alter the displayed plan. */
|
|
50
|
+
export function parseBootstrapPlan(input) {
|
|
51
|
+
const value = record(input);
|
|
52
|
+
exactKeys(value, [
|
|
53
|
+
"schema",
|
|
54
|
+
"home",
|
|
55
|
+
"origin",
|
|
56
|
+
"platform",
|
|
57
|
+
"arch",
|
|
58
|
+
"cliPath",
|
|
59
|
+
"skillsPath",
|
|
60
|
+
"statePath",
|
|
61
|
+
"dailyUpdate",
|
|
62
|
+
"artifacts",
|
|
63
|
+
]);
|
|
64
|
+
if (value.schema !== 1 ||
|
|
65
|
+
(value.platform !== "darwin" && value.platform !== "linux") ||
|
|
66
|
+
(value.arch !== "arm64" && value.arch !== "x64") ||
|
|
67
|
+
typeof value.dailyUpdate !== "boolean" ||
|
|
68
|
+
!Array.isArray(value.artifacts) ||
|
|
69
|
+
value.artifacts.length !== 4)
|
|
70
|
+
refused();
|
|
71
|
+
const kinds = new Set();
|
|
72
|
+
const artifacts = value.artifacts
|
|
73
|
+
.map((input) => {
|
|
74
|
+
const item = record(input);
|
|
75
|
+
exactKeys(item, ["kind", "version", "sha256"]);
|
|
76
|
+
if (item.kind !== "runtime" &&
|
|
77
|
+
item.kind !== "cli" &&
|
|
78
|
+
item.kind !== "skills" &&
|
|
79
|
+
item.kind !== "installer")
|
|
80
|
+
refused();
|
|
81
|
+
if (kinds.has(item.kind))
|
|
82
|
+
refused();
|
|
83
|
+
kinds.add(item.kind);
|
|
84
|
+
const version = text(item.version);
|
|
85
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$/.test(version) ||
|
|
86
|
+
typeof item.sha256 !== "string" ||
|
|
87
|
+
!/^[a-f0-9]{64}$/.test(item.sha256))
|
|
88
|
+
refused();
|
|
89
|
+
return Object.freeze({ kind: item.kind, version, sha256: item.sha256 });
|
|
90
|
+
})
|
|
91
|
+
.sort((a, b) => a.kind.localeCompare(b.kind));
|
|
92
|
+
const home = path(value.home), cliPath = path(value.cliPath), skillsPath = path(value.skillsPath), statePath = path(value.statePath);
|
|
93
|
+
if (new Set([home, cliPath, skillsPath, statePath]).size !== 4)
|
|
94
|
+
refused();
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
schema: 1,
|
|
97
|
+
home,
|
|
98
|
+
origin: origin(value.origin),
|
|
99
|
+
platform: value.platform,
|
|
100
|
+
arch: value.arch,
|
|
101
|
+
cliPath,
|
|
102
|
+
skillsPath,
|
|
103
|
+
statePath,
|
|
104
|
+
dailyUpdate: value.dailyUpdate,
|
|
105
|
+
artifacts: Object.freeze(artifacts),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export function bootstrapPlanHash(plan) {
|
|
109
|
+
return createHash("sha256")
|
|
110
|
+
.update(JSON.stringify(parseBootstrapPlan(plan)))
|
|
111
|
+
.digest("hex");
|
|
112
|
+
}
|
|
113
|
+
export function bootstrapPlanLines(plan) {
|
|
114
|
+
return [
|
|
115
|
+
`Install on ${plan.platform}/${plan.arch} for ${plan.home}`,
|
|
116
|
+
`Catalyst cloud: ${plan.origin}`,
|
|
117
|
+
`Command: ${plan.cliPath}`,
|
|
118
|
+
`Skills: ${plan.skillsPath}`,
|
|
119
|
+
`Setup record: ${plan.statePath}`,
|
|
120
|
+
`Daily update: ${plan.dailyUpdate ? "schedule after installation" : "not selected"}`,
|
|
121
|
+
...plan.artifacts.map((artifact) => `${artifact.kind} ${artifact.version} (SHA-256 ${artifact.sha256})`),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
/** A prepared native continuation is consumed before entering the first persistent machine stage.
|
|
125
|
+
* Retry after a partial install constructs a new preview from the actual machine and journal.
|
|
126
|
+
*/
|
|
127
|
+
export function createBootstrapPreview(input, ports) {
|
|
128
|
+
const plan = parseBootstrapPlan(input);
|
|
129
|
+
const planHash = bootstrapPlanHash(plan);
|
|
130
|
+
let consumed = false;
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
plan,
|
|
133
|
+
async recheck() {
|
|
134
|
+
if (consumed)
|
|
135
|
+
refused();
|
|
136
|
+
await ports.recheck();
|
|
137
|
+
},
|
|
138
|
+
assertCurrent() {
|
|
139
|
+
if (consumed)
|
|
140
|
+
refused();
|
|
141
|
+
ports.assertCurrent();
|
|
142
|
+
},
|
|
143
|
+
async continue(approved, signal) {
|
|
144
|
+
if (consumed ||
|
|
145
|
+
signal.aborted ||
|
|
146
|
+
approved.planHash !== planHash ||
|
|
147
|
+
bootstrapPlanHash(approved.plan) !== planHash ||
|
|
148
|
+
approved.origin !== plan.origin ||
|
|
149
|
+
!approved.account ||
|
|
150
|
+
!approved.person ||
|
|
151
|
+
!approved.runId ||
|
|
152
|
+
!Number.isSafeInteger(approved.ownerPid) ||
|
|
153
|
+
approved.ownerPid < 1 ||
|
|
154
|
+
!approved.ownerToken ||
|
|
155
|
+
!isAbsolute(approved.lockPath))
|
|
156
|
+
refused();
|
|
157
|
+
consumed = true;
|
|
158
|
+
ports.assertCurrent();
|
|
159
|
+
await ports.continue(approved, signal);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -119,7 +119,7 @@ async function read(ctx, journal, timeoutMs, external) {
|
|
|
119
119
|
!["GET", "POST"].includes(row.method) ||
|
|
120
120
|
typeof row.path !== "string" ||
|
|
121
121
|
row.path.length > 240 ||
|
|
122
|
-
|
|
122
|
+
!(/^\/(?:api\/v1\/|connect\/|me\/)[A-Za-z0-9_/-]+$/.test(row.path) || row.path === "/api/v1/coding-accounts/:slot/validate") ||
|
|
123
123
|
row.personalBearer !== true)
|
|
124
124
|
return { reason: "onboarding_capability_unavailable", origin };
|
|
125
125
|
const key = `${row.method} ${row.path}`;
|
|
@@ -142,6 +142,17 @@ async function read(ctx, journal, timeoutMs, external) {
|
|
|
142
142
|
remove();
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
+
/** Read current route support for context checks and selected writes without refreshing a token. */
|
|
146
|
+
export async function verifyOnboardRoutes(ctx, binding, requirements, signal, timeoutMs = 30_000) {
|
|
147
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000)
|
|
148
|
+
throw new Error("onboarding_capability_timeout_invalid");
|
|
149
|
+
const result = await read(ctx, binding, timeoutMs, signal);
|
|
150
|
+
if ("reason" in result)
|
|
151
|
+
return result;
|
|
152
|
+
if (requirements.some((route) => !result.capabilities.routes.has(routeKey(route))))
|
|
153
|
+
return { reason: "cloud_capability_unavailable", origin: result.origin };
|
|
154
|
+
return { origin: result.origin };
|
|
155
|
+
}
|
|
145
156
|
/** Guard both the observation and action before their first endpoint request. A route's real
|
|
146
157
|
* refusal is preserved only after the same-account cloud advertised personal bearer support. */
|
|
147
158
|
export function guardOnboardCapabilities(adapter, options) {
|
|
@@ -149,12 +160,9 @@ export function guardOnboardCapabilities(adapter, options) {
|
|
|
149
160
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000)
|
|
150
161
|
throw new Error("onboarding_capability_timeout_invalid");
|
|
151
162
|
const run = async (ctx, journal, signal, requirements, work) => {
|
|
152
|
-
const advertised = await
|
|
153
|
-
if ("reason" in advertised
|
|
154
|
-
|
|
155
|
-
const reason = "reason" in advertised
|
|
156
|
-
? advertised.reason
|
|
157
|
-
: "cloud_capability_unavailable";
|
|
163
|
+
const advertised = await verifyOnboardRoutes(ctx, journal, requirements, signal, timeoutMs);
|
|
164
|
+
if ("reason" in advertised) {
|
|
165
|
+
const reason = advertised.reason;
|
|
158
166
|
const url = advertised.origin
|
|
159
167
|
? `${advertised.origin}${safePages[options.fallback]}`
|
|
160
168
|
: undefined;
|
|
@@ -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
|
}
|