@catalyst-cloud/cli 0.14.5 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -3
  3. package/dist/args.js +51 -13
  4. package/dist/cli.js +58 -10
  5. package/dist/config.js +16 -3
  6. package/dist/daemon-authority-lifetime.js +171 -0
  7. package/dist/daemon-authority.js +216 -0
  8. package/dist/daemon-cache.js +348 -0
  9. package/dist/daemon-store.js +370 -0
  10. package/dist/daemon-supervisor.js +460 -0
  11. package/dist/env/onboard-draft-store.js +243 -0
  12. package/dist/env/onboard-draft.js +33 -0
  13. package/dist/env/selected-import-writer.js +163 -0
  14. package/dist/env/settings-authority.js +203 -0
  15. package/dist/events-cloud-tail.js +134 -0
  16. package/dist/events-cloud.js +500 -0
  17. package/dist/events.js +13 -4
  18. package/dist/legacy.js +145 -32
  19. package/dist/oauth.js +125 -33
  20. package/dist/onboard-accounts.js +259 -0
  21. package/dist/onboard-automation-management.js +15 -0
  22. package/dist/onboard-bootstrap.js +162 -0
  23. package/dist/onboard-capabilities.js +15 -7
  24. package/dist/onboard-capacity.js +226 -0
  25. package/dist/onboard-existing.js +106 -32
  26. package/dist/onboard-file-snapshot.js +45 -0
  27. package/dist/onboard-login-candidate.js +261 -0
  28. package/dist/onboard-projects.js +350 -0
  29. package/dist/onboard-repositories.js +147 -37
  30. package/dist/onboard-runtime.js +104 -9
  31. package/dist/onboard-settings-context.js +141 -0
  32. package/dist/onboard-settings-import.js +127 -0
  33. package/dist/onboard-settings.js +234 -0
  34. package/dist/onboard-ui.js +99 -11
  35. package/dist/onboard-workflow.js +316 -0
  36. package/dist/onboard.js +359 -27
  37. package/dist/owned-fetch.js +182 -0
  38. package/dist/query.js +77 -27
  39. package/dist/ready.js +143 -35
  40. package/dist/replica.js +11 -4
  41. package/dist/sdk.js +33 -0
  42. package/dist/watch.js +2 -2
  43. package/package.json +3 -2
  44. package/skills/catalyst-github/SKILL.md +2 -2
  45. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  46. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  47. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  48. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  49. package/skills/catalyst-linear/SKILL.md +3 -3
  50. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  51. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  52. package/skills/catalyst-onboard/SKILL.md +5 -4
  53. package/skills/catalyst-onboard/references/local-sync.md +3 -1
  54. package/skills/run-this-project/SKILL.md +1 -1
  55. package/skills/unstick/SKILL.md +1 -1
  56. package/skills/what-needs-me/SKILL.md +1 -1
  57. package/skills/what-this-repo-needs/SKILL.md +1 -1
  58. package/skills/whats-happening/SKILL.md +2 -2
  59. package/skills/whats-happening/references/status-reply.md +1 -1
  60. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  61. package/vendor/settings/index.d.ts +4 -0
  62. package/vendor/settings/index.js +2937 -0
  63. package/vendor/settings/provenance.json +148 -0
@@ -1,44 +1,105 @@
1
- import { loadConfig, normalizeBaseUrl, readManifest } from "./config.js";
1
+ import { loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
2
2
  import { contractVersionInRange } from "./contract.js";
3
- import { readExistingOnboardJson, selectedOnboardTeam } from "./onboard-existing.js";
3
+ import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
4
4
  const part = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
5
5
  const id = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
6
- const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
6
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
7
+ ? value
8
+ : null;
7
9
  const key = (row) => `${row.owner}/${row.name}`.toLowerCase();
8
- const waiting = (reason) => ({ state: "waiting", reason });
10
+ const waiting = (reason) => ({
11
+ state: "waiting",
12
+ reason,
13
+ });
9
14
  const valid = (value) => {
10
15
  const row = object(value);
11
- return !!row && typeof row.teamId === "string" && id.test(row.teamId) && typeof row.repoId === "string" && id.test(row.repoId) &&
12
- typeof row.owner === "string" && part.test(row.owner) && typeof row.name === "string" && part.test(row.name);
16
+ return (!!row &&
17
+ typeof row.teamId === "string" &&
18
+ id.test(row.teamId) &&
19
+ typeof row.repoId === "string" &&
20
+ id.test(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 step = journal?.steps.find(row => row.id === "github.repos" && row.state === "done");
17
- const receipt = step?.evidence?.repository;
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 || !rows.every(valid))
35
+ if (!Array.isArray(rows) || !rows.length || rows.length > 100)
23
36
  return [];
24
- if (new Set(rows.map(key)).size !== rows.length)
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
- return rows.map(row => ({ teamId: row.teamId, owner: row.owner, name: row.name, repoId: row.repoId }));
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 inventory(ctx, journal, signal) {
80
+ export async function readOnboardRepositoryInventory(ctx, journal, signal) {
34
81
  const cfg = loadConfig(ctx.home);
35
- if (!cfg?.user || !journal.account || cfg.account !== journal.account || journal.membershipId !== cfg.user.id ||
36
- !journal.baseUrl || normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
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 || typeof row.teamId !== "string" || !id.test(row.teamId) || typeof row.owner !== "string" || !part.test(row.owner) ||
52
- typeof row.name !== "string" || !part.test(row.name))
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,37 @@ 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 || account?.id !== journal.account || typeof doc.contractVersion !== "string" ||
140
+ if (!doc ||
141
+ account?.id !== journal.account ||
142
+ typeof doc.contractVersion !== "string" ||
71
143
  contractVersionInRange(doc.contractVersion, readManifest().tenantContractRange) !== true ||
72
- !merge || !Array.isArray(merge.repositories) || merge.repositories.length > 10_000)
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 || typeof row.owner !== "string" || !part.test(row.owner) || typeof row.name !== "string" || !part.test(row.name) ||
79
- typeof row.repoId !== "string" || !id.test(row.repoId))
152
+ if (!row ||
153
+ typeof row.owner !== "string" ||
154
+ !part.test(row.owner) ||
155
+ typeof row.name !== "string" ||
156
+ !part.test(row.name) ||
157
+ typeof row.repoId !== "string" ||
158
+ !id.test(row.repoId))
80
159
  return { reason: "repository_contract_unverified" };
81
160
  const name = key({ owner: row.owner, name: row.name });
82
161
  if (idNames.has(row.repoId) && idNames.get(row.repoId) !== name)
@@ -99,34 +178,57 @@ export function existingRepositoryAdapter(args, choose) {
99
178
  const explicit = args.flags.repo;
100
179
  if (explicit !== undefined) {
101
180
  const names = Array.isArray(explicit) ? explicit : [explicit];
102
- if (!names.length || names.length > 100 || !names.every(name => typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(name)))
181
+ if (!names.length ||
182
+ names.length > 100 ||
183
+ !names.every((name) => typeof name === "string" &&
184
+ /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(name)))
103
185
  return null;
104
- const wanted = names.map(name => String(name).toLowerCase());
186
+ const wanted = names.map((name) => String(name).toLowerCase());
105
187
  if (new Set(wanted).size !== wanted.length)
106
188
  return null;
107
- const selected = wanted.map(name => repositories.find(row => key(row) === name));
108
- return selected.every((row) => !!row) ? selected : null;
189
+ const selected = wanted.map((name) => repositories.find((row) => key(row) === name));
190
+ return selected.every((row) => !!row)
191
+ ? selected
192
+ : null;
109
193
  }
110
194
  const saved = chosen ?? selectedOnboardRepositories(journal);
111
195
  if (!saved.length)
112
196
  return null;
113
- const selected = saved.map(row => repositories.find(live => key(live) === key(row) && live.teamId === row.teamId && live.repoId === row.repoId));
114
- return selected.every((row) => !!row) ? selected : null;
197
+ const selected = saved.map((row) => repositories.find((live) => key(live) === key(row) &&
198
+ live.teamId === row.teamId &&
199
+ live.repoId === row.repoId));
200
+ return selected.every((row) => !!row)
201
+ ? selected
202
+ : null;
115
203
  };
116
204
  const check = async (ctx, journal, signal) => {
117
- const list = await inventory(ctx, journal, signal);
205
+ const list = await readOnboardRepositoryInventory(ctx, journal, signal);
118
206
  if ("reason" in list)
119
207
  return waiting(list.reason);
120
208
  if (!list.repositories.length)
121
209
  return waiting("repository_inventory_empty");
122
210
  const rows = requested(list.repositories, journal);
123
211
  if (!rows)
124
- return args.flags.repo !== undefined || chosen || selectedOnboardRepositories(journal).length
125
- ? waiting("repository_selection_unverified") : choose ? { state: "pending" } : waiting("repository_choice_required");
126
- return { state: "done", evidence: { repository: JSON.stringify(rows), count: rows.length, checkedAt: ctx.now().getTime() } };
212
+ return args.flags.repo !== undefined ||
213
+ chosen ||
214
+ selectedOnboardRepositories(journal).length
215
+ ? waiting("repository_selection_unverified")
216
+ : choose
217
+ ? { state: "pending" }
218
+ : waiting("repository_choice_required");
219
+ return {
220
+ state: "done",
221
+ evidence: {
222
+ repository: JSON.stringify(rows),
223
+ count: rows.length,
224
+ checkedAt: ctx.now().getTime(),
225
+ },
226
+ };
127
227
  };
128
- return { check, act: async (ctx, journal, signal) => {
129
- const list = await inventory(ctx, journal, signal);
228
+ return {
229
+ check,
230
+ act: async (ctx, journal, signal) => {
231
+ const list = await readOnboardRepositoryInventory(ctx, journal, signal);
130
232
  if ("reason" in list)
131
233
  return waiting(list.reason);
132
234
  if (!list.repositories.length)
@@ -142,19 +244,27 @@ export function existingRepositoryAdapter(args, choose) {
142
244
  }
143
245
  signal?.addEventListener("abort", stopped, { once: true });
144
246
  remove = () => signal?.removeEventListener("abort", stopped);
145
- Promise.resolve().then(() => signal?.aborted ? null : choose(list.repositories.map(row => ({ ...row })))).then(resolve, reject);
247
+ Promise.resolve()
248
+ .then(() => signal?.aborted
249
+ ? null
250
+ : choose(list.repositories.map((row) => ({ ...row }))))
251
+ .then(resolve, reject);
146
252
  }).finally(() => remove());
147
253
  if (!answer || signal?.aborted)
148
254
  return waiting("interrupted");
149
- if (!Array.isArray(answer) || !answer.length || answer.length > 100 || !answer.every(name => typeof name === "string"))
255
+ if (!Array.isArray(answer) ||
256
+ !answer.length ||
257
+ answer.length > 100 ||
258
+ !answer.every((name) => typeof name === "string"))
150
259
  return waiting("repository_selection_unverified");
151
- const names = answer.map(name => name.toLowerCase());
260
+ const names = answer.map((name) => name.toLowerCase());
152
261
  if (new Set(names).size !== names.length)
153
262
  return waiting("repository_selection_unverified");
154
- const rows = names.map(name => list.repositories.find(row => key(row) === name));
263
+ const rows = names.map((name) => list.repositories.find((row) => key(row) === name));
155
264
  if (!rows.every((row) => !!row))
156
265
  return waiting("repository_selection_unverified");
157
266
  chosen = rows;
158
267
  return check(ctx, journal, signal);
159
- } };
268
+ },
269
+ };
160
270
  }
@@ -5,14 +5,18 @@ import { openBrowser } from "./browser.js";
5
5
  import { defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
6
6
  import { CliError } from "./errors.js";
7
7
  import { cmdLegacy, findLegacy } from "./legacy.js";
8
- import { bearerFor } from "./oauth.js";
9
8
  import { personalConsentAdapter } from "./onboard-personal.js";
10
9
  import { boundedOnboardSignin } from "./onboard-signin.js";
11
10
  import { existingLinearAdapters } from "./onboard-existing.js";
12
11
  import { linearWorkspaceAdapter } from "./onboard-workspace.js";
13
- import { existingRepositoryAdapter } from "./onboard-repositories.js";
12
+ import { firstProjectAdapters } from "./onboard-projects.js";
13
+ import { onboardCapacityAdapter } from "./onboard-capacity.js";
14
+ import { onboardAutomationManagementAdapter } from "./onboard-automation-management.js";
15
+ import { onboardWorkflowVerificationAdapter } from "./onboard-workflow.js";
16
+ import { onboardAccountsAdapter } from "./onboard-accounts.js";
14
17
  import { githubInstallationAdapter } from "./onboard-github.js";
15
18
  import { guardOnboardCapabilities, } from "./onboard-capabilities.js";
19
+ import { onboardSettingsAdapter, } from "./onboard-settings.js";
16
20
  import { fetchMe } from "./transport.js";
17
21
  const waiting = (reason) => ({
18
22
  state: "waiting",
@@ -45,7 +49,56 @@ export function createOnboardRuntime(args, ctx, hooks) {
45
49
  (journal.baseUrl &&
46
50
  normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))))
47
51
  throw new CliError("the saved setup belongs to another workspace or member", "onboard-identity-mismatch", 12);
48
- const me = await fetchMe(cfg.baseUrl, await bearerFor(stepCtx, cfg), stepCtx.fetch);
52
+ // The Q1 preview reads the current token directly; stopping setup must not rotate saved credentials.
53
+ const expiry = cfg.auth
54
+ ? Date.parse(cfg.auth.expiresAt) - stepCtx.now().getTime()
55
+ : 0;
56
+ const bearer = cfg.key ||
57
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000
58
+ ? cfg.auth.accessToken
59
+ : undefined);
60
+ if (!bearer)
61
+ throw new CliError("Renew your login with catalyst login, then run catalyst onboard.", "onboard-login-refresh-required", 11);
62
+ const owned = new AbortController();
63
+ const signal = hooks.ui
64
+ ? AbortSignal.any([owned.signal, hooks.ui.signal])
65
+ : owned.signal;
66
+ const timer = setTimeout(() => owned.abort(), 30_000);
67
+ let remove = () => { };
68
+ let me;
69
+ try {
70
+ me = await new Promise((resolve, reject) => {
71
+ const stopped = () => reject(new CliError("Your membership could not be checked. Run catalyst onboard to retry.", "onboard-membership-unavailable", 11));
72
+ if (signal.aborted) {
73
+ stopped();
74
+ return;
75
+ }
76
+ signal.addEventListener("abort", stopped, { once: true });
77
+ remove = () => signal.removeEventListener("abort", stopped);
78
+ const boundedFetch = ((url, init) => {
79
+ if (signal.aborted)
80
+ return Promise.reject(new Error("membership_read_stopped"));
81
+ return stepCtx.fetch(url, {
82
+ ...init,
83
+ redirect: "error",
84
+ signal: init?.signal
85
+ ? AbortSignal.any([signal, init.signal])
86
+ : signal,
87
+ });
88
+ });
89
+ fetchMe(cfg.baseUrl, bearer, boundedFetch).then((value) => (signal.aborted ? stopped() : resolve(value)), reject);
90
+ });
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ remove();
95
+ }
96
+ const current = loadConfig(stepCtx.home);
97
+ if (!current?.user ||
98
+ current.account !== cfg.account ||
99
+ current.user.id !== cfg.user.id ||
100
+ normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
101
+ throw new CliError("Your saved login changed while membership was checked. Run catalyst onboard again.", "onboard-identity-mismatch", 12);
49
102
  if (!me.user || me.account !== cfg.account || me.user.id !== cfg.user.id)
50
103
  throw new CliError("the saved login and live member do not match", "onboard-identity-mismatch", 12);
51
104
  // /me names a person within the account, not an independently exposed membership-row ID.
@@ -54,6 +107,12 @@ export function createOnboardRuntime(args, ctx, hooks) {
54
107
  membershipId: me.user.id,
55
108
  baseUrl: normalizeBaseUrl(cfg.baseUrl),
56
109
  role: me.user.role,
110
+ display: {
111
+ personLabel: me.user.label,
112
+ email: me.user.email,
113
+ workspaceName: me.name,
114
+ workspaceSlug: me.slug,
115
+ },
57
116
  };
58
117
  };
59
118
  const legacyCheck = async (stepCtx) => {
@@ -160,9 +219,36 @@ export function createOnboardRuntime(args, ctx, hooks) {
160
219
  : undefined,
161
220
  sleep: hooks.sleep,
162
221
  }),
163
- "github.repos": existingRepositoryAdapter(args, hooks.ui?.chooseRepositories
164
- ? (repositories) => hooks.ui.chooseRepositories(repositories.map((row) => ({ ...row })))
165
- : undefined),
222
+ settings: onboardSettingsAdapter({
223
+ ...hooks.settings,
224
+ review: hooks.ui?.reviewSettings
225
+ ? (summaries) => hooks.ui.reviewSettings(summaries)
226
+ : hooks.settings?.review,
227
+ message: hooks.ui
228
+ ? (text) => hooks.ui.message(text)
229
+ : (text) => ctx.stderr(text),
230
+ }),
231
+ ...firstProjectAdapters(args, {
232
+ chooseExisting: hooks.ui?.chooseRepositories
233
+ ? (rows) => hooks.ui.chooseRepositories(rows.map((row) => ({ ...row })))
234
+ : undefined,
235
+ chooseFirst: hooks.ui?.chooseFirstRepository
236
+ ? (rows) => hooks.ui.chooseFirstRepository(rows.map((row) => ({ ...row })))
237
+ : undefined,
238
+ message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
239
+ }),
240
+ accounts: onboardAccountsAdapter({
241
+ message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
242
+ }),
243
+ capacity: onboardCapacityAdapter({
244
+ message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
245
+ }),
246
+ "linear.adopt": onboardWorkflowVerificationAdapter({
247
+ message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
248
+ }),
249
+ "linear.automations": onboardAutomationManagementAdapter({
250
+ message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
251
+ }),
166
252
  "linear.personal": personalAdapter("linear"),
167
253
  "github.personal": personalAdapter("github"),
168
254
  daemon: {
@@ -220,11 +306,19 @@ export function createOnboardRuntime(args, ctx, hooks) {
220
306
  ],
221
307
  fallback: "connections",
222
308
  },
223
- "linear.automations": {
224
- check: [get("/api/v1/agent/tenant/readiness")],
309
+ "github.repos": {
310
+ check: [get("/api/v1/repos"), get("/api/v1/agent/contract")],
225
311
  fallback: "connections",
226
312
  },
227
- "github.repos": {
313
+ settings: {
314
+ check: [
315
+ get("/api/v1/agent/teams"),
316
+ get("/api/v1/repos"),
317
+ get("/api/v1/agent/contract"),
318
+ ],
319
+ fallback: "connections",
320
+ },
321
+ projects: {
228
322
  check: [get("/api/v1/repos"), get("/api/v1/agent/contract")],
229
323
  fallback: "connections",
230
324
  },
@@ -246,6 +340,7 @@ export function createOnboardRuntime(args, ctx, hooks) {
246
340
  // tenant-bound step guards can run. Unsupported/local-only steps make no endpoint requests.
247
341
  return {
248
342
  identity: (journal) => identity(ctx, journal),
343
+ ...(hooks.stageSignin ? { stageSignin: hooks.stageSignin } : {}),
249
344
  bindSignals: true,
250
345
  ui: hooks.ui,
251
346
  adapters,
@@ -0,0 +1,141 @@
1
+ import { verifyOnboardRoutes } from "./onboard-capabilities.js";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
5
+ import { readExistingOnboardJson, readOnboardTeamInventory, selectedOnboardTeam, } from "./onboard-existing.js";
6
+ import { readOnboardRepositoryInventory, selectedOnboardRepositories, } from "./onboard-repositories.js";
7
+ import { readLocalSettings, } from "./env/settings-authority.js";
8
+ const run = promisify(execFile);
9
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
10
+ ? value
11
+ : null;
12
+ const repoKey = (repo) => `${repo.owner}/${repo.name}`.toLowerCase();
13
+ /** Read only Git's local origin; no credential helper, provider call, hook or repo command runs. */
14
+ export async function readLocalRepositoryName(repoRoot, signal) {
15
+ try {
16
+ if (signal?.aborted)
17
+ return null;
18
+ const result = await run("git", ["config", "--local", "--get-all", "remote.origin.url"], {
19
+ cwd: repoRoot,
20
+ timeout: 3_000,
21
+ maxBuffer: 16_384,
22
+ signal,
23
+ env: {
24
+ PATH: process.env.PATH,
25
+ GIT_CONFIG_GLOBAL: "/dev/null",
26
+ GIT_CONFIG_SYSTEM: "/dev/null",
27
+ GIT_CONFIG_NOSYSTEM: "1",
28
+ GIT_TERMINAL_PROMPT: "0",
29
+ },
30
+ });
31
+ const urls = result.stdout.trim().split(/\r?\n/);
32
+ if (urls.length !== 1 || signal?.aborted)
33
+ return null;
34
+ const raw = urls[0];
35
+ if (/[\u0000-\u0020\u007f]/.test(raw))
36
+ return null;
37
+ const ssh = /^git@github\.com:([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(raw);
38
+ if (ssh)
39
+ return `${ssh[1]}/${ssh[2]}`.toLowerCase();
40
+ const url = new URL(raw);
41
+ if (url.protocol !== "https:" ||
42
+ url.hostname !== "github.com" ||
43
+ url.port ||
44
+ url.username ||
45
+ url.password ||
46
+ url.search ||
47
+ url.hash)
48
+ return null;
49
+ const path = url.pathname.replace(/^\//, "").replace(/\.git$/, "");
50
+ return /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/.test(path)
51
+ ? path.toLowerCase()
52
+ : null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** Fresh account/member, team inventory and personal repository ACL establish authority. The
59
+ * journal chooses identifiers only; neither its done state nor the disk contract is proof. */
60
+ export function onboardSettingsAuthorityPorts(input) {
61
+ const selected = { ...input.repository };
62
+ const root = input.repoRoot;
63
+ return {
64
+ readSettings: readLocalSettings,
65
+ readContext: async (signal) => {
66
+ try {
67
+ const { ctx, journal } = input;
68
+ const cfg = loadConfig(ctx.home);
69
+ const account = journal.account ?? journal.tenant;
70
+ if (signal?.aborted ||
71
+ !cfg?.user ||
72
+ !account ||
73
+ account !== cfg.account ||
74
+ journal.membershipId !== cfg.user.id ||
75
+ !journal.baseUrl ||
76
+ normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
77
+ return null;
78
+ const teamId = selectedOnboardTeam(journal);
79
+ if (!teamId ||
80
+ teamId !== selected.teamId ||
81
+ !selectedOnboardRepositories(journal).some((repo) => repoKey(repo) === repoKey(selected) &&
82
+ repo.repoId === selected.repoId &&
83
+ repo.teamId === teamId))
84
+ return null;
85
+ const supported = await verifyOnboardRoutes(ctx, journal, [
86
+ { method: "GET", path: "/api/v1/agent/teams" },
87
+ { method: "GET", path: "/api/v1/repos" },
88
+ { method: "GET", path: "/api/v1/agent/contract" },
89
+ ], signal);
90
+ if ("reason" in supported || signal?.aborted)
91
+ return null;
92
+ const meRead = await readExistingOnboardJson(ctx, "/api/v1/me", signal);
93
+ if ("reason" in meRead)
94
+ return null;
95
+ const me = object(meRead.body);
96
+ const user = object(me?.user);
97
+ if (me?.account !== account ||
98
+ user?.id !== cfg.user.id ||
99
+ typeof user.role !== "string" ||
100
+ !["owner", "admin"].includes(user.role))
101
+ return null;
102
+ const teams = await readOnboardTeamInventory(ctx, signal);
103
+ if ("reason" in teams)
104
+ return null;
105
+ const team = teams.teams.find((row) => row.id === teamId);
106
+ if (!team || !/^[A-Z][A-Z0-9]{0,15}$/.test(team.key))
107
+ return null;
108
+ const inventory = await readOnboardRepositoryInventory(ctx, journal, signal);
109
+ if ("reason" in inventory ||
110
+ !inventory.repositories.some((repo) => repoKey(repo) === repoKey(selected) &&
111
+ repo.repoId === selected.repoId &&
112
+ repo.teamId === selected.teamId))
113
+ return null;
114
+ const local = await (input.readRepositoryName ?? readLocalRepositoryName)(root, signal);
115
+ if (local !== repoKey(selected) || signal?.aborted)
116
+ return null;
117
+ const current = loadConfig(ctx.home);
118
+ if (!current?.user ||
119
+ current.account !== cfg.account ||
120
+ current.user.id !== cfg.user.id ||
121
+ normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl) ||
122
+ !["owner", "admin"].includes(current.user.role))
123
+ return null;
124
+ return {
125
+ accountId: account,
126
+ personId: cfg.user.id,
127
+ baseUrl: normalizeBaseUrl(cfg.baseUrl),
128
+ role: user.role,
129
+ teamId,
130
+ teamKey: team.key,
131
+ repoId: selected.repoId,
132
+ repoName: repoKey(selected),
133
+ repoRoot: root,
134
+ };
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ },
140
+ };
141
+ }