@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -5
  3. package/dist/args.js +68 -18
  4. package/dist/browser.js +37 -3
  5. package/dist/cli.js +58 -12
  6. package/dist/config.js +17 -5
  7. package/dist/consent-browser.js +58 -0
  8. package/dist/daemon-authority-lifetime.js +171 -0
  9. package/dist/daemon-authority.js +216 -0
  10. package/dist/daemon-cache.js +348 -0
  11. package/dist/daemon-store.js +370 -0
  12. package/dist/daemon-supervisor.js +460 -0
  13. package/dist/env/onboard-draft-store.js +250 -0
  14. package/dist/env/onboard-draft.js +33 -0
  15. package/dist/env/selected-import-writer.js +163 -0
  16. package/dist/env/settings-authority.js +205 -0
  17. package/dist/events-cloud-tail.js +146 -0
  18. package/dist/events-cloud.js +619 -0
  19. package/dist/events.js +186 -31
  20. package/dist/oauth.js +129 -34
  21. package/dist/onboard-accounts.js +259 -0
  22. package/dist/onboard-automation-management.js +15 -0
  23. package/dist/onboard-bootstrap.js +162 -0
  24. package/dist/onboard-capabilities.js +15 -7
  25. package/dist/onboard-capacity.js +226 -0
  26. package/dist/onboard-existing.js +106 -32
  27. package/dist/onboard-file-snapshot.js +45 -0
  28. package/dist/onboard-github.js +14 -3
  29. package/dist/onboard-login-candidate.js +261 -0
  30. package/dist/onboard-next.js +139 -0
  31. package/dist/onboard-personal.js +14 -3
  32. package/dist/onboard-projects.js +350 -0
  33. package/dist/onboard-repositories.js +146 -37
  34. package/dist/onboard-runtime.js +108 -13
  35. package/dist/onboard-settings-context.js +141 -0
  36. package/dist/onboard-settings-import.js +127 -0
  37. package/dist/onboard-settings.js +234 -0
  38. package/dist/onboard-ui.js +85 -59
  39. package/dist/onboard-workflow.js +316 -0
  40. package/dist/onboard-workspace.js +14 -3
  41. package/dist/onboard.js +422 -39
  42. package/dist/owned-fetch.js +182 -0
  43. package/dist/pagination.js +13 -1
  44. package/dist/query.js +104 -31
  45. package/dist/ready.js +143 -35
  46. package/dist/replica.js +11 -4
  47. package/dist/repository-id.js +5 -0
  48. package/dist/sdk.js +33 -0
  49. package/dist/watch.js +2 -2
  50. package/package.json +3 -2
  51. package/skills/catalyst-github/SKILL.md +4 -2
  52. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  53. package/skills/catalyst-github/references/local-replica.md +78 -0
  54. package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
  55. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  56. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  57. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  58. package/skills/catalyst-linear/SKILL.md +5 -3
  59. package/skills/catalyst-linear/references/local-replica.md +91 -0
  60. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  61. package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
  62. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  63. package/skills/catalyst-onboard/SKILL.md +6 -4
  64. package/skills/catalyst-onboard/references/local-replica.md +40 -0
  65. package/skills/catalyst-onboard/references/local-sync.md +8 -4
  66. package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
  67. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  68. package/skills/run-this-project/SKILL.md +1 -1
  69. package/skills/unstick/SKILL.md +1 -1
  70. package/skills/what-needs-me/SKILL.md +1 -1
  71. package/skills/what-this-repo-needs/SKILL.md +1 -1
  72. package/skills/whats-happening/SKILL.md +4 -2
  73. package/skills/whats-happening/references/local-replica.md +51 -0
  74. package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
  75. package/skills/whats-happening/references/status-reply.md +1 -1
  76. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  77. package/vendor/settings/index.d.ts +4 -0
  78. package/vendor/settings/index.js +2937 -0
  79. package/vendor/settings/provenance.json +148 -0
@@ -0,0 +1,250 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isRepositoryId } from "../repository-id.js";
3
+ import { constants } from "node:fs";
4
+ import { lstat, mkdir, open, unlink } from "node:fs/promises";
5
+ import { isAbsolute, join, parse, resolve, sep } from "node:path";
6
+ import { onboardStateRoot } from "../onboard.js";
7
+ const OPAQUE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
8
+ const MAX_TOML_BYTES = 1024 * 1024;
9
+ const defaultFs = { mkdir, lstat, open, unlink };
10
+ /**
11
+ * Persist a private copy of a draft produced and fully validated by the caller. This function
12
+ * checks the storage boundary only; it does not validate settings or inspect TOML values.
13
+ */
14
+ export async function storeOnboardDraft(input, fsOps = defaultFs) {
15
+ const home = input?.home;
16
+ const env = input?.env;
17
+ const runId = input?.runId;
18
+ const repoId = input?.repoId;
19
+ const toml = input?.toml;
20
+ const signal = input?.signal;
21
+ if (typeof runId !== "string" ||
22
+ !OPAQUE_ID_RE.test(runId) ||
23
+ !isRepositoryId(repoId) ||
24
+ typeof home !== "string" ||
25
+ !isAbsolute(home) ||
26
+ typeof toml !== "string" ||
27
+ Buffer.byteLength(toml, "utf8") > MAX_TOML_BYTES ||
28
+ signal?.aborted)
29
+ return { state: "rejected" };
30
+ // Snapshot the expected bytes and resolved paths before the first asynchronous operation.
31
+ const expected = Buffer.from(toml, "utf8");
32
+ let stateRoot;
33
+ let runDir;
34
+ let destination;
35
+ try {
36
+ stateRoot = resolve(onboardStateRoot(home, env));
37
+ if (stateRoot === parse(stateRoot).root || stateRoot === resolve(home))
38
+ return { state: "rejected" };
39
+ runDir = join(stateRoot, "install", "drafts", runId);
40
+ // Keep earlier slug filenames resumable. New cloud IDs use a portable bounded filename;
41
+ // punctuation normalization would collide and raw IDs can exceed NAME_MAX.
42
+ // The @ namespace cannot collide with any earlier slug filename.
43
+ const filename = OPAQUE_ID_RE.test(repoId)
44
+ ? `${repoId}.toml`
45
+ : `repo@${createHash("sha256").update(repoId, "utf8").digest("hex")}.toml`;
46
+ destination = join(runDir, filename);
47
+ }
48
+ catch {
49
+ return { state: "rejected" };
50
+ }
51
+ let handle;
52
+ let created;
53
+ try {
54
+ await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
55
+ if (aborted(signal))
56
+ return { state: "rejected" };
57
+ try {
58
+ handle = await fsOps.open(destination, constants.O_WRONLY |
59
+ constants.O_CREAT |
60
+ constants.O_EXCL |
61
+ constants.O_NOFOLLOW, 0o600);
62
+ }
63
+ catch (error) {
64
+ if (!isAlreadyExists(error))
65
+ throw error;
66
+ return await resumeExisting(destination, runDir, stateRoot, expected, signal, fsOps);
67
+ }
68
+ const opened = await handle.stat();
69
+ created = { dev: opened.dev, ino: opened.ino };
70
+ if (!opened.isFile() || opened.nlink !== 1 || !privateMode(opened.mode))
71
+ throw new Error("unsafe artifact");
72
+ if (aborted(signal))
73
+ throw new Error("cancelled");
74
+ await handle.writeFile(expected);
75
+ if (aborted(signal))
76
+ throw new Error("cancelled");
77
+ await handle.sync();
78
+ if (aborted(signal))
79
+ throw new Error("cancelled");
80
+ await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
81
+ if (aborted(signal))
82
+ throw new Error("cancelled");
83
+ await verifyCreatedPath(destination, created, fsOps);
84
+ if (aborted(signal))
85
+ throw new Error("cancelled");
86
+ await handle.close();
87
+ handle = undefined;
88
+ if (aborted(signal))
89
+ throw new Error("cancelled");
90
+ await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
91
+ if (aborted(signal))
92
+ throw new Error("cancelled");
93
+ await verifyCreatedPath(destination, created, fsOps);
94
+ if (aborted(signal))
95
+ throw new Error("cancelled");
96
+ return { state: "stored", path: destination };
97
+ }
98
+ catch {
99
+ if (handle !== undefined) {
100
+ try {
101
+ await handle.close();
102
+ }
103
+ catch {
104
+ /* settle close before inode-scoped cleanup */
105
+ }
106
+ }
107
+ if (created !== undefined)
108
+ await removeCreatedFile(destination, created, fsOps);
109
+ return { state: "rejected" };
110
+ }
111
+ }
112
+ async function resumeExisting(path, runDir, stateRoot, expected, signal, fsOps) {
113
+ let handle;
114
+ try {
115
+ if (aborted(signal))
116
+ return { state: "rejected" };
117
+ await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
118
+ if (aborted(signal))
119
+ return { state: "rejected" };
120
+ handle = await fsOps.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
121
+ const before = await handle.stat();
122
+ const pathBefore = await fsOps.lstat(path);
123
+ if (!sameSafeFile(before, pathBefore) || aborted(signal))
124
+ throw new Error("unsafe resume file");
125
+ const chunks = [];
126
+ let total = 0;
127
+ for await (const chunk of handle.createReadStream({ autoClose: false })) {
128
+ if (aborted(signal))
129
+ throw new Error("cancelled");
130
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
131
+ total += bytes.length;
132
+ if (total > MAX_TOML_BYTES)
133
+ throw new Error("oversized resume file");
134
+ chunks.push(bytes);
135
+ }
136
+ if (aborted(signal))
137
+ throw new Error("cancelled");
138
+ const content = Buffer.concat(chunks, total);
139
+ if (!content.equals(expected))
140
+ throw new Error("different resume bytes");
141
+ const fdAfter = await handle.stat();
142
+ const pathAfter = await fsOps.lstat(path);
143
+ if (!sameIdentity(before, fdAfter) ||
144
+ !sameSafeFile(fdAfter, pathAfter) ||
145
+ aborted(signal))
146
+ throw new Error("resume path changed");
147
+ await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
148
+ if (aborted(signal))
149
+ throw new Error("cancelled");
150
+ await handle.close();
151
+ handle = undefined;
152
+ if (aborted(signal))
153
+ throw new Error("cancelled");
154
+ await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
155
+ if (aborted(signal))
156
+ throw new Error("cancelled");
157
+ const finalPath = await fsOps.lstat(path);
158
+ if (!sameSafeFile(before, finalPath) || aborted(signal))
159
+ throw new Error("resume path changed");
160
+ return { state: "stored", path };
161
+ }
162
+ catch {
163
+ if (handle !== undefined) {
164
+ try {
165
+ await handle.close();
166
+ }
167
+ catch {
168
+ /* resume never owns the existing file */
169
+ }
170
+ }
171
+ return { state: "rejected" };
172
+ }
173
+ }
174
+ async function ensureDirectoryTree(directory, stateRoot, signal, fsOps) {
175
+ const absolute = resolve(directory);
176
+ const root = parse(absolute).root;
177
+ const parts = absolute.slice(root.length).split(sep).filter(Boolean);
178
+ let current = root;
179
+ for (const part of parts) {
180
+ if (aborted(signal))
181
+ throw new Error("cancelled");
182
+ current = join(current, part);
183
+ try {
184
+ await fsOps.mkdir(current, { mode: 0o700 });
185
+ }
186
+ catch (error) {
187
+ if (!isAlreadyExists(error))
188
+ throw error;
189
+ }
190
+ if (aborted(signal))
191
+ throw new Error("cancelled");
192
+ const stat = await fsOps.lstat(current);
193
+ if (aborted(signal))
194
+ throw new Error("cancelled");
195
+ if (stat.isSymbolicLink() || !stat.isDirectory())
196
+ throw new Error("redirected directory");
197
+ if (isWithin(stateRoot, current) &&
198
+ ((stat.mode & 0o022) !== 0 ||
199
+ (process.getuid !== undefined && stat.uid !== process.getuid())))
200
+ throw new Error("unsafe storage directory");
201
+ }
202
+ }
203
+ async function verifyCreatedPath(path, identity, fsOps) {
204
+ const stat = await fsOps.lstat(path);
205
+ if (!stat.isFile() ||
206
+ stat.isSymbolicLink() ||
207
+ stat.nlink !== 1 ||
208
+ !privateMode(stat.mode) ||
209
+ !sameIdentity(stat, identity))
210
+ throw new Error("artifact path changed");
211
+ }
212
+ async function removeCreatedFile(path, identity, fsOps) {
213
+ try {
214
+ const stat = await fsOps.lstat(path);
215
+ if (stat.isFile() && !stat.isSymbolicLink() && sameIdentity(stat, identity))
216
+ await fsOps.unlink(path);
217
+ }
218
+ catch {
219
+ /* missing or replaced paths are untouched */
220
+ }
221
+ }
222
+ function sameSafeFile(fd, path) {
223
+ return (fd.isFile() &&
224
+ path.isFile() &&
225
+ !path.isSymbolicLink() &&
226
+ fd.nlink === 1 &&
227
+ path.nlink === 1 &&
228
+ privateMode(fd.mode) &&
229
+ privateMode(path.mode) &&
230
+ (process.getuid === undefined || fd.uid === process.getuid()) &&
231
+ sameIdentity(fd, path));
232
+ }
233
+ function sameIdentity(a, b) {
234
+ return a.dev === b.dev && a.ino === b.ino;
235
+ }
236
+ function privateMode(mode) {
237
+ return (mode & 0o077) === 0;
238
+ }
239
+ function isWithin(root, path) {
240
+ return path === root || path.startsWith(`${root}${sep}`);
241
+ }
242
+ function aborted(signal) {
243
+ return signal?.aborted === true;
244
+ }
245
+ function isAlreadyExists(error) {
246
+ return (typeof error === "object" &&
247
+ error !== null &&
248
+ "code" in error &&
249
+ error.code === "EEXIST");
250
+ }
@@ -0,0 +1,33 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { draftEnvironment } from "./draft.js";
4
+ const TEAM_KEY_RE = /^[A-Z][A-Z0-9]{0,15}$/;
5
+ const DESTINATION = ".catalyst/catalyst.toml";
6
+ /** Compose a value-free first settings draft. This does not validate the full settings schema or write files. */
7
+ export function draftOnboardSettings(root, teamKey, deps = {}) {
8
+ if (typeof teamKey !== "string" || !TEAM_KEY_RE.test(teamKey)) {
9
+ return {
10
+ state: "invalid",
11
+ errors: [
12
+ "team key must use uppercase letters and digits and start with a letter",
13
+ ],
14
+ };
15
+ }
16
+ const destinationPath = join(root, DESTINATION);
17
+ if ((deps.fileExists ?? existsSync)(destinationPath)) {
18
+ return { state: "existing", destination: DESTINATION };
19
+ }
20
+ const environment = draftEnvironment(root, deps);
21
+ if (environment.state === "invalid")
22
+ return environment;
23
+ return {
24
+ state: "draft",
25
+ teamKey,
26
+ names: environment.names,
27
+ secretNames: environment.secretNames,
28
+ setup: environment.setup,
29
+ verify: environment.verify,
30
+ sources: environment.sources,
31
+ toml: `[project]\nlinear_team = ${JSON.stringify(teamKey)}\n\n${environment.toml}`,
32
+ };
33
+ }
@@ -0,0 +1,163 @@
1
+ import { envFileInventory, filterEnvFileNames, parseEnvAssignments, } from "./parse-env.js";
2
+ const NAME_RE = /^[A-Z][A-Z0-9_]*$/;
3
+ const SAFE_UNCERTAIN = "selected import outcome uncertain";
4
+ const SAFE_INPUT = "selected import input is invalid";
5
+ function fixedError(message) {
6
+ return new Error(message);
7
+ }
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ function validateSelectedAssignments(selectedText, selectedNames) {
12
+ if (typeof selectedText !== "string" ||
13
+ !Array.isArray(selectedNames) ||
14
+ !selectedNames.every((name) => typeof name === "string" && NAME_RE.test(name)) ||
15
+ new Set(selectedNames).size !== selectedNames.length) {
16
+ throw fixedError(SAFE_INPUT);
17
+ }
18
+ const inventory = envFileInventory(selectedText);
19
+ if (inventory.duplicateNames.length > 0 ||
20
+ inventory.names.length !== selectedNames.length ||
21
+ inventory.names.some((name) => !selectedNames.includes(name))) {
22
+ throw fixedError(SAFE_INPUT);
23
+ }
24
+ const entries = parseEnvAssignments(selectedText);
25
+ if (entries.length !== selectedNames.length)
26
+ throw fixedError(SAFE_INPUT);
27
+ const values = new Map();
28
+ for (const entry of entries) {
29
+ if (!NAME_RE.test(entry.name) ||
30
+ !selectedNames.includes(entry.name) ||
31
+ values.has(entry.name)) {
32
+ throw fixedError(SAFE_INPUT);
33
+ }
34
+ values.set(entry.name, entry.value);
35
+ }
36
+ if (selectedNames.some((name) => !values.has(name)))
37
+ throw fixedError(SAFE_INPUT);
38
+ return values;
39
+ }
40
+ function validVariableSuccess(reply, name) {
41
+ if (!isRecord(reply.body))
42
+ return false;
43
+ const envVar = reply.body.envVar;
44
+ const unresolvedReferences = reply.body.unresolvedReferences;
45
+ return (isRecord(envVar) &&
46
+ envVar.name === name &&
47
+ typeof reply.body.created === "boolean" &&
48
+ Array.isArray(unresolvedReferences) &&
49
+ unresolvedReferences.every((entry) => typeof entry === "string"));
50
+ }
51
+ function createVariableWriter(writeVariable) {
52
+ return async (selectedText, selectedNames) => {
53
+ const ownedNames = Array.isArray(selectedNames)
54
+ ? [...selectedNames]
55
+ : selectedNames;
56
+ if (Array.isArray(ownedNames) && ownedNames.length === 0)
57
+ return { written: [], failed: [] };
58
+ const values = validateSelectedAssignments(selectedText, ownedNames);
59
+ const written = [];
60
+ const failed = [];
61
+ for (const name of ownedNames) {
62
+ let reply;
63
+ try {
64
+ reply = await writeVariable(name, values.get(name));
65
+ }
66
+ catch {
67
+ throw fixedError(SAFE_UNCERTAIN);
68
+ }
69
+ try {
70
+ if (!isRecord(reply) || !Number.isInteger(reply.status)) {
71
+ throw fixedError(SAFE_UNCERTAIN);
72
+ }
73
+ if (reply.status === 200 || reply.status === 201) {
74
+ if (!validVariableSuccess(reply, name))
75
+ throw fixedError(SAFE_UNCERTAIN);
76
+ written.push(name);
77
+ }
78
+ else if ([400, 401, 403, 409, 422].includes(reply.status)) {
79
+ failed.push(name);
80
+ }
81
+ else {
82
+ throw fixedError(SAFE_UNCERTAIN);
83
+ }
84
+ }
85
+ catch {
86
+ throw fixedError(SAFE_UNCERTAIN);
87
+ }
88
+ }
89
+ return { written, failed };
90
+ };
91
+ }
92
+ function secretOutcomes(reply, selectedNames) {
93
+ if (reply.status !== 200 || !isRecord(reply.body))
94
+ return null;
95
+ const { created, rotated, errors } = reply.body;
96
+ if (!Array.isArray(created) ||
97
+ !Array.isArray(rotated) ||
98
+ !Array.isArray(errors)) {
99
+ return null;
100
+ }
101
+ const failed = [];
102
+ for (const error of errors) {
103
+ if (!isRecord(error) || typeof error.name !== "string")
104
+ return null;
105
+ failed.push(error.name);
106
+ }
107
+ const written = [...created, ...rotated];
108
+ const outcomes = [...written, ...failed];
109
+ if (!outcomes.every((name) => typeof name === "string" && NAME_RE.test(name)) ||
110
+ outcomes.length !== selectedNames.length ||
111
+ new Set(outcomes).size !== outcomes.length ||
112
+ outcomes.some((name) => !selectedNames.includes(name)) ||
113
+ selectedNames.some((name) => !outcomes.includes(name))) {
114
+ return null;
115
+ }
116
+ return { written, failed };
117
+ }
118
+ function createSecretWriter(importSecrets) {
119
+ return async (selectedText, selectedNames) => {
120
+ const ownedNames = Array.isArray(selectedNames)
121
+ ? [...selectedNames]
122
+ : selectedNames;
123
+ if (Array.isArray(ownedNames) && ownedNames.length === 0)
124
+ return { written: [], failed: [] };
125
+ validateSelectedAssignments(selectedText, ownedNames);
126
+ const filtered = filterEnvFileNames(selectedText, new Set(ownedNames));
127
+ if (filtered.found.length !== ownedNames.length)
128
+ throw fixedError(SAFE_INPUT);
129
+ let reply;
130
+ try {
131
+ reply = await importSecrets(filtered.text, [...ownedNames]);
132
+ }
133
+ catch {
134
+ throw fixedError(SAFE_UNCERTAIN);
135
+ }
136
+ try {
137
+ if (!isRecord(reply) || !Number.isInteger(reply.status)) {
138
+ throw fixedError(SAFE_UNCERTAIN);
139
+ }
140
+ const outcome = secretOutcomes(reply, ownedNames);
141
+ if (outcome === null)
142
+ throw fixedError(SAFE_UNCERTAIN);
143
+ return outcome;
144
+ }
145
+ catch {
146
+ throw fixedError(SAFE_UNCERTAIN);
147
+ }
148
+ };
149
+ }
150
+ /** Adapt the CLI's existing single-variable and batch-secret writers for the value-free Q4 helper. */
151
+ export function createSelectedImportWriter(options) {
152
+ if (options.kind === "variable") {
153
+ if (options.writeVariable === undefined)
154
+ throw fixedError(SAFE_INPUT);
155
+ return createVariableWriter(options.writeVariable);
156
+ }
157
+ if (options.kind === "secret") {
158
+ if (options.importSecrets === undefined)
159
+ throw fixedError(SAFE_INPUT);
160
+ return createSecretWriter(options.importSecrets);
161
+ }
162
+ throw fixedError(SAFE_INPUT);
163
+ }
@@ -0,0 +1,205 @@
1
+ import { isRepositoryId } from "../repository-id.js";
2
+ import { createHash } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import { lstat, open, realpath } from "node:fs/promises";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
+ import { validateFullSettings } from "../../vendor/settings/index.js";
7
+ import { executeSelectedNameImport, planSelectedNameImportFromText, } from "./selected-import.js";
8
+ const proofs = new WeakMap();
9
+ const refused = () => ({
10
+ state: "rejected",
11
+ message: "Review the current repository settings before importing values",
12
+ });
13
+ const opaque = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
14
+ function snapshotContext(raw) {
15
+ if (!raw ||
16
+ !isRepositoryId(raw.repoId) ||
17
+ ![raw.accountId, raw.personId, raw.teamId].every((id) => typeof id === "string" && opaque.test(id)))
18
+ return null;
19
+ if (!["owner", "admin", "member"].includes(raw.role) ||
20
+ !/^[A-Z][A-Z0-9]{0,15}$/.test(raw.teamKey))
21
+ return null;
22
+ if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/.test(raw.repoName) ||
23
+ !isAbsolute(raw.repoRoot))
24
+ return null;
25
+ try {
26
+ const url = new URL(raw.baseUrl);
27
+ if (url.protocol !== "https:" ||
28
+ url.username ||
29
+ url.password ||
30
+ url.search ||
31
+ url.hash ||
32
+ url.pathname !== "/")
33
+ return null;
34
+ return {
35
+ accountId: raw.accountId,
36
+ personId: raw.personId,
37
+ baseUrl: url.origin,
38
+ role: raw.role,
39
+ teamId: raw.teamId,
40
+ teamKey: raw.teamKey,
41
+ repoId: raw.repoId,
42
+ repoName: raw.repoName.toLowerCase(),
43
+ repoRoot: resolve(raw.repoRoot),
44
+ };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** Bounded exact local source: refuse redirected roots/directories/files and read a regular file.
51
+ * The descriptor belongs to this call and is closed even when reading or cancellation fails. */
52
+ export async function readLocalSettings(repoRoot, signal) {
53
+ let handle;
54
+ try {
55
+ if (signal?.aborted || !isAbsolute(repoRoot))
56
+ return null;
57
+ const root = resolve(repoRoot);
58
+ const directory = join(root, ".catalyst");
59
+ if ((await realpath(root)) !== root ||
60
+ (await realpath(directory)) !== directory ||
61
+ signal?.aborted)
62
+ return null;
63
+ const path = join(directory, "catalyst.toml");
64
+ const expected = await lstat(path);
65
+ if (!expected.isFile() || expected.size > 1_048_576)
66
+ return null;
67
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
68
+ const stat = await handle.stat();
69
+ if (!stat.isFile() ||
70
+ stat.dev !== expected.dev ||
71
+ stat.ino !== expected.ino ||
72
+ stat.size > 1_048_576 ||
73
+ signal?.aborted)
74
+ return null;
75
+ const bytes = await handle.readFile();
76
+ if (bytes.length > 1_048_576 || signal?.aborted)
77
+ return null;
78
+ const current = await lstat(path);
79
+ if (current.dev !== stat.dev ||
80
+ current.ino !== stat.ino ||
81
+ (await realpath(root)) !== root ||
82
+ (await realpath(directory)) !== directory ||
83
+ signal?.aborted)
84
+ return null;
85
+ // Invalid UTF-8 must not be normalized into a different reviewed source.
86
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
87
+ return text;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ finally {
93
+ await handle?.close().catch(() => { });
94
+ }
95
+ }
96
+ async function readCurrentSource(ports, kind, signal) {
97
+ if (signal?.aborted)
98
+ return null;
99
+ const before = snapshotContext(await ports.readContext(signal));
100
+ // Both current variable and secret write rails require an administrator or owner.
101
+ if (!before || !["owner", "admin"].includes(before.role) || signal?.aborted)
102
+ return null;
103
+ const text = await ports.readSettings(before.repoRoot, signal);
104
+ const after = snapshotContext(await ports.readContext(signal));
105
+ if (text === null ||
106
+ !after ||
107
+ signal?.aborted ||
108
+ JSON.stringify(before) !== JSON.stringify(after))
109
+ return null;
110
+ const checked = validateFullSettings(text);
111
+ if (!checked.ok || checked.linearTeam !== before.teamKey)
112
+ return null;
113
+ const sourceBinding = createHash("sha256")
114
+ .update(JSON.stringify([
115
+ before,
116
+ createHash("sha256").update(text).digest("hex"),
117
+ "90d646c7d2f1e52348625e76aeb504d56042e54e",
118
+ kind,
119
+ ]))
120
+ .digest("hex");
121
+ return {
122
+ sourceBinding,
123
+ names: kind === "secrets" ? checked.secretNames : checked.variableNames,
124
+ };
125
+ }
126
+ /** Authority reads are read-only. A stalled injected port cannot hold the CLI indefinitely;
127
+ * late completion observes the owned abort signal and cannot produce an accepted source. */
128
+ async function currentSource(ports, kind, signal) {
129
+ if (signal?.aborted)
130
+ return null;
131
+ const owned = new AbortController();
132
+ const combined = signal
133
+ ? AbortSignal.any([signal, owned.signal])
134
+ : owned.signal;
135
+ let timer;
136
+ let remove = () => { };
137
+ try {
138
+ return await new Promise((resolve) => {
139
+ const stopped = () => resolve(null);
140
+ combined.addEventListener("abort", stopped, { once: true });
141
+ remove = () => combined.removeEventListener("abort", stopped);
142
+ timer = setTimeout(() => owned.abort(), 30_000);
143
+ timer.unref();
144
+ Promise.resolve()
145
+ .then(() => readCurrentSource(ports, kind, combined))
146
+ .then((source) => resolve(combined.aborted ? null : source), () => resolve(null));
147
+ });
148
+ }
149
+ finally {
150
+ if (timer)
151
+ clearTimeout(timer);
152
+ remove();
153
+ }
154
+ }
155
+ export async function reviewSettingsImport(input) {
156
+ try {
157
+ if (input.kind !== "variables" && input.kind !== "secrets")
158
+ return refused();
159
+ const source = await currentSource(input.ports, input.kind, input.signal);
160
+ if (!source)
161
+ return refused();
162
+ const plan = planSelectedNameImportFromText({
163
+ text: input.text,
164
+ requestedNames: [...input.requestedNames],
165
+ allowedNames: source.names,
166
+ });
167
+ if (plan.state !== "ready")
168
+ return refused();
169
+ const review = Object.freeze({
170
+ state: "ready",
171
+ kind: input.kind,
172
+ selectedNames: Object.freeze([...plan.selectedNames]),
173
+ });
174
+ proofs.set(review, {
175
+ kind: input.kind,
176
+ sourceBinding: source.sourceBinding,
177
+ plan,
178
+ });
179
+ return review;
180
+ }
181
+ catch {
182
+ return refused();
183
+ }
184
+ }
185
+ /** Review objects are process-local capabilities. A serialized receipt, reconstructed object or
186
+ * changed settings/context cannot authorize a write; values remain inside the selected writer. */
187
+ export async function executeReviewedSettingsImport(input) {
188
+ try {
189
+ const proof = proofs.get(input.review);
190
+ if (!proof)
191
+ return refused();
192
+ // Claim synchronously, before the first await: concurrent attempts cannot share capability.
193
+ // A rejected or cancelled execution also requires a new review.
194
+ proofs.delete(input.review);
195
+ const source = await currentSource(input.ports, proof.kind, input.signal);
196
+ if (!source ||
197
+ source.sourceBinding !== proof.sourceBinding ||
198
+ input.signal?.aborted)
199
+ return refused();
200
+ return await executeSelectedNameImport(input.text, proof.plan, source.names, input.writer);
201
+ }
202
+ catch {
203
+ return refused();
204
+ }
205
+ }