@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.
- package/CHANGELOG.md +9 -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/legacy.js +145 -32
- 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,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,203 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { validateFullSettings } from "../../vendor/settings/index.js";
|
|
6
|
+
import { executeSelectedNameImport, planSelectedNameImportFromText, } from "./selected-import.js";
|
|
7
|
+
const proofs = new WeakMap();
|
|
8
|
+
const refused = () => ({
|
|
9
|
+
state: "rejected",
|
|
10
|
+
message: "Review the current repository settings before importing values",
|
|
11
|
+
});
|
|
12
|
+
const opaque = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
13
|
+
function snapshotContext(raw) {
|
|
14
|
+
if (!raw ||
|
|
15
|
+
![raw.accountId, raw.personId, raw.teamId, raw.repoId].every((id) => typeof id === "string" && opaque.test(id)))
|
|
16
|
+
return null;
|
|
17
|
+
if (!["owner", "admin", "member"].includes(raw.role) ||
|
|
18
|
+
!/^[A-Z][A-Z0-9]{0,15}$/.test(raw.teamKey))
|
|
19
|
+
return null;
|
|
20
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/.test(raw.repoName) ||
|
|
21
|
+
!isAbsolute(raw.repoRoot))
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(raw.baseUrl);
|
|
25
|
+
if (url.protocol !== "https:" ||
|
|
26
|
+
url.username ||
|
|
27
|
+
url.password ||
|
|
28
|
+
url.search ||
|
|
29
|
+
url.hash ||
|
|
30
|
+
url.pathname !== "/")
|
|
31
|
+
return null;
|
|
32
|
+
return {
|
|
33
|
+
accountId: raw.accountId,
|
|
34
|
+
personId: raw.personId,
|
|
35
|
+
baseUrl: url.origin,
|
|
36
|
+
role: raw.role,
|
|
37
|
+
teamId: raw.teamId,
|
|
38
|
+
teamKey: raw.teamKey,
|
|
39
|
+
repoId: raw.repoId,
|
|
40
|
+
repoName: raw.repoName.toLowerCase(),
|
|
41
|
+
repoRoot: resolve(raw.repoRoot),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Bounded exact local source: refuse redirected roots/directories/files and read a regular file.
|
|
49
|
+
* The descriptor belongs to this call and is closed even when reading or cancellation fails. */
|
|
50
|
+
export async function readLocalSettings(repoRoot, signal) {
|
|
51
|
+
let handle;
|
|
52
|
+
try {
|
|
53
|
+
if (signal?.aborted || !isAbsolute(repoRoot))
|
|
54
|
+
return null;
|
|
55
|
+
const root = resolve(repoRoot);
|
|
56
|
+
const directory = join(root, ".catalyst");
|
|
57
|
+
if ((await realpath(root)) !== root ||
|
|
58
|
+
(await realpath(directory)) !== directory ||
|
|
59
|
+
signal?.aborted)
|
|
60
|
+
return null;
|
|
61
|
+
const path = join(directory, "catalyst.toml");
|
|
62
|
+
const expected = await lstat(path);
|
|
63
|
+
if (!expected.isFile() || expected.size > 1_048_576)
|
|
64
|
+
return null;
|
|
65
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
66
|
+
const stat = await handle.stat();
|
|
67
|
+
if (!stat.isFile() ||
|
|
68
|
+
stat.dev !== expected.dev ||
|
|
69
|
+
stat.ino !== expected.ino ||
|
|
70
|
+
stat.size > 1_048_576 ||
|
|
71
|
+
signal?.aborted)
|
|
72
|
+
return null;
|
|
73
|
+
const bytes = await handle.readFile();
|
|
74
|
+
if (bytes.length > 1_048_576 || signal?.aborted)
|
|
75
|
+
return null;
|
|
76
|
+
const current = await lstat(path);
|
|
77
|
+
if (current.dev !== stat.dev ||
|
|
78
|
+
current.ino !== stat.ino ||
|
|
79
|
+
(await realpath(root)) !== root ||
|
|
80
|
+
(await realpath(directory)) !== directory ||
|
|
81
|
+
signal?.aborted)
|
|
82
|
+
return null;
|
|
83
|
+
// Invalid UTF-8 must not be normalized into a different reviewed source.
|
|
84
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await handle?.close().catch(() => { });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function readCurrentSource(ports, kind, signal) {
|
|
95
|
+
if (signal?.aborted)
|
|
96
|
+
return null;
|
|
97
|
+
const before = snapshotContext(await ports.readContext(signal));
|
|
98
|
+
// Both current variable and secret write rails require an administrator or owner.
|
|
99
|
+
if (!before || !["owner", "admin"].includes(before.role) || signal?.aborted)
|
|
100
|
+
return null;
|
|
101
|
+
const text = await ports.readSettings(before.repoRoot, signal);
|
|
102
|
+
const after = snapshotContext(await ports.readContext(signal));
|
|
103
|
+
if (text === null ||
|
|
104
|
+
!after ||
|
|
105
|
+
signal?.aborted ||
|
|
106
|
+
JSON.stringify(before) !== JSON.stringify(after))
|
|
107
|
+
return null;
|
|
108
|
+
const checked = validateFullSettings(text);
|
|
109
|
+
if (!checked.ok || checked.linearTeam !== before.teamKey)
|
|
110
|
+
return null;
|
|
111
|
+
const sourceBinding = createHash("sha256")
|
|
112
|
+
.update(JSON.stringify([
|
|
113
|
+
before,
|
|
114
|
+
createHash("sha256").update(text).digest("hex"),
|
|
115
|
+
"90d646c7d2f1e52348625e76aeb504d56042e54e",
|
|
116
|
+
kind,
|
|
117
|
+
]))
|
|
118
|
+
.digest("hex");
|
|
119
|
+
return {
|
|
120
|
+
sourceBinding,
|
|
121
|
+
names: kind === "secrets" ? checked.secretNames : checked.variableNames,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Authority reads are read-only. A stalled injected port cannot hold the CLI indefinitely;
|
|
125
|
+
* late completion observes the owned abort signal and cannot produce an accepted source. */
|
|
126
|
+
async function currentSource(ports, kind, signal) {
|
|
127
|
+
if (signal?.aborted)
|
|
128
|
+
return null;
|
|
129
|
+
const owned = new AbortController();
|
|
130
|
+
const combined = signal
|
|
131
|
+
? AbortSignal.any([signal, owned.signal])
|
|
132
|
+
: owned.signal;
|
|
133
|
+
let timer;
|
|
134
|
+
let remove = () => { };
|
|
135
|
+
try {
|
|
136
|
+
return await new Promise((resolve) => {
|
|
137
|
+
const stopped = () => resolve(null);
|
|
138
|
+
combined.addEventListener("abort", stopped, { once: true });
|
|
139
|
+
remove = () => combined.removeEventListener("abort", stopped);
|
|
140
|
+
timer = setTimeout(() => owned.abort(), 30_000);
|
|
141
|
+
timer.unref();
|
|
142
|
+
Promise.resolve()
|
|
143
|
+
.then(() => readCurrentSource(ports, kind, combined))
|
|
144
|
+
.then((source) => resolve(combined.aborted ? null : source), () => resolve(null));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
if (timer)
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
remove();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export async function reviewSettingsImport(input) {
|
|
154
|
+
try {
|
|
155
|
+
if (input.kind !== "variables" && input.kind !== "secrets")
|
|
156
|
+
return refused();
|
|
157
|
+
const source = await currentSource(input.ports, input.kind, input.signal);
|
|
158
|
+
if (!source)
|
|
159
|
+
return refused();
|
|
160
|
+
const plan = planSelectedNameImportFromText({
|
|
161
|
+
text: input.text,
|
|
162
|
+
requestedNames: [...input.requestedNames],
|
|
163
|
+
allowedNames: source.names,
|
|
164
|
+
});
|
|
165
|
+
if (plan.state !== "ready")
|
|
166
|
+
return refused();
|
|
167
|
+
const review = Object.freeze({
|
|
168
|
+
state: "ready",
|
|
169
|
+
kind: input.kind,
|
|
170
|
+
selectedNames: Object.freeze([...plan.selectedNames]),
|
|
171
|
+
});
|
|
172
|
+
proofs.set(review, {
|
|
173
|
+
kind: input.kind,
|
|
174
|
+
sourceBinding: source.sourceBinding,
|
|
175
|
+
plan,
|
|
176
|
+
});
|
|
177
|
+
return review;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return refused();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Review objects are process-local capabilities. A serialized receipt, reconstructed object or
|
|
184
|
+
* changed settings/context cannot authorize a write; values remain inside the selected writer. */
|
|
185
|
+
export async function executeReviewedSettingsImport(input) {
|
|
186
|
+
try {
|
|
187
|
+
const proof = proofs.get(input.review);
|
|
188
|
+
if (!proof)
|
|
189
|
+
return refused();
|
|
190
|
+
// Claim synchronously, before the first await: concurrent attempts cannot share capability.
|
|
191
|
+
// A rejected or cancelled execution also requires a new review.
|
|
192
|
+
proofs.delete(input.review);
|
|
193
|
+
const source = await currentSource(input.ports, proof.kind, input.signal);
|
|
194
|
+
if (!source ||
|
|
195
|
+
source.sourceBinding !== proof.sourceBinding ||
|
|
196
|
+
input.signal?.aborted)
|
|
197
|
+
return refused();
|
|
198
|
+
return await executeSelectedNameImport(input.text, proof.plan, source.names, input.writer);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return refused();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { cloudEvents } from "./events-cloud.js";
|
|
2
|
+
/** The same matching rules as the local tail, so a filter means one thing from either source. */
|
|
3
|
+
export function eventMatches(filter) {
|
|
4
|
+
const ticket = filter.ticket?.toUpperCase();
|
|
5
|
+
return (event) => (!filter.type || event.type === filter.type) &&
|
|
6
|
+
(!ticket || payloadReferences(event.payload, ticket));
|
|
7
|
+
}
|
|
8
|
+
function payloadReferences(value, ticket) {
|
|
9
|
+
if (typeof value === "string")
|
|
10
|
+
return value.toUpperCase() === ticket;
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return value.some((item) => payloadReferences(item, ticket));
|
|
13
|
+
if (!value || typeof value !== "object")
|
|
14
|
+
return false;
|
|
15
|
+
return Object.entries(value).some(([key, item]) => ["ticket", "identifier", "workItemKey", "issueIdentifier"].includes(key) && typeof item === "string"
|
|
16
|
+
? item.toUpperCase() === ticket
|
|
17
|
+
: payloadReferences(item, ticket));
|
|
18
|
+
}
|
|
19
|
+
function pipeClosed(error) {
|
|
20
|
+
const code = error?.code;
|
|
21
|
+
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
|
|
22
|
+
}
|
|
23
|
+
// Resolves once the line has left this process, so a consumer sees it before the next is read.
|
|
24
|
+
function writeLine(out, line, signal) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
if (signal.aborted)
|
|
27
|
+
return resolve();
|
|
28
|
+
const abort = () => resolve();
|
|
29
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
30
|
+
const settle = (error) => {
|
|
31
|
+
signal.removeEventListener("abort", abort);
|
|
32
|
+
if (error)
|
|
33
|
+
reject(error);
|
|
34
|
+
else
|
|
35
|
+
resolve();
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
out.write(line, (error) => settle(error));
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
settle(error);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Print every matching cloud event as one NDJSON line until aborted, matched, or the pipe closes. */
|
|
46
|
+
export async function tailCloudEvents(ctx, options, deps = {}) {
|
|
47
|
+
const { out } = options;
|
|
48
|
+
const filter = options.filter ?? {};
|
|
49
|
+
const filtered = eventMatches(filter);
|
|
50
|
+
const matches = (event) => filtered(event) && (options.where?.(event) ?? true);
|
|
51
|
+
const reader = new AbortController();
|
|
52
|
+
const stop = () => reader.abort(options.signal.reason);
|
|
53
|
+
if (options.signal.aborted)
|
|
54
|
+
stop();
|
|
55
|
+
else
|
|
56
|
+
options.signal.addEventListener("abort", stop, { once: true });
|
|
57
|
+
let closed = false;
|
|
58
|
+
let failure;
|
|
59
|
+
const broken = (error) => {
|
|
60
|
+
if (pipeClosed(error))
|
|
61
|
+
closed = true;
|
|
62
|
+
else
|
|
63
|
+
failure ??= error;
|
|
64
|
+
reader.abort(error);
|
|
65
|
+
};
|
|
66
|
+
out.on("error", broken);
|
|
67
|
+
let cursor = options.after;
|
|
68
|
+
let written = 0;
|
|
69
|
+
let matched;
|
|
70
|
+
// The server narrows what it sends when it can; every event is still matched here.
|
|
71
|
+
const serverFilter = {
|
|
72
|
+
...(filter.type ? { type: filter.type } : {}),
|
|
73
|
+
...(filter.ticket ? { ticket: filter.ticket.toUpperCase() } : {}),
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
const events = (deps.events ?? cloudEvents)(ctx, { after: options.after, filter: serverFilter, signal: reader.signal }, deps);
|
|
77
|
+
for await (const event of events) {
|
|
78
|
+
if (reader.signal.aborted)
|
|
79
|
+
break;
|
|
80
|
+
// A reconnect may replay what was already printed; the sequence is the identity.
|
|
81
|
+
if (cursor !== undefined && event.sequence <= cursor)
|
|
82
|
+
continue;
|
|
83
|
+
if (matches(event)) {
|
|
84
|
+
await writeLine(out, `${JSON.stringify(event)}\n`, reader.signal);
|
|
85
|
+
// Aborted mid-write: the line was not confirmed, so it does not move the cursor.
|
|
86
|
+
if (reader.signal.aborted)
|
|
87
|
+
break;
|
|
88
|
+
written += 1;
|
|
89
|
+
cursor = event.sequence;
|
|
90
|
+
if (options.once) {
|
|
91
|
+
matched = event;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else
|
|
96
|
+
cursor = event.sequence;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (pipeClosed(error))
|
|
101
|
+
closed = true;
|
|
102
|
+
else if (!reader.signal.aborted)
|
|
103
|
+
failure ??= error;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
options.signal.removeEventListener("abort", stop);
|
|
107
|
+
reader.abort();
|
|
108
|
+
// After EPIPE the stream can still emit its error; the listener stays so that cannot crash.
|
|
109
|
+
if (!closed)
|
|
110
|
+
out.off("error", broken);
|
|
111
|
+
}
|
|
112
|
+
if (failure !== undefined)
|
|
113
|
+
throw failure;
|
|
114
|
+
if (matched)
|
|
115
|
+
return { outcome: "matched", cursor, written, event: matched };
|
|
116
|
+
if (closed)
|
|
117
|
+
return { outcome: "closed", cursor, written };
|
|
118
|
+
return { outcome: options.signal.aborted ? "aborted" : "ended", cursor, written };
|
|
119
|
+
}
|
|
120
|
+
/** Print the first matching cloud event and stop, or report a timeout with nothing printed. */
|
|
121
|
+
export async function waitForCloudEvent(ctx, options, deps = {}) {
|
|
122
|
+
const timer = new AbortController();
|
|
123
|
+
const timeout = setTimeout(() => timer.abort(new Error("event wait timed out")), options.timeoutMs);
|
|
124
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timer.signal]) : timer.signal;
|
|
125
|
+
try {
|
|
126
|
+
const result = await tailCloudEvents(ctx, { after: options.after, filter: options.filter, out: options.out, signal, where: options.predicate, once: true }, deps);
|
|
127
|
+
if (result.outcome === "aborted" && timer.signal.aborted && !options.signal?.aborted)
|
|
128
|
+
return { ...result, outcome: "timeout" };
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
}
|
|
134
|
+
}
|