@catalyst-cloud/cli 0.14.6 → 0.14.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +7 -5
- package/dist/args.js +68 -18
- package/dist/browser.js +37 -3
- package/dist/cli.js +58 -12
- package/dist/config.js +17 -5
- package/dist/consent-browser.js +58 -0
- package/dist/daemon-authority-lifetime.js +171 -0
- package/dist/daemon-authority.js +216 -0
- package/dist/daemon-cache.js +348 -0
- package/dist/daemon-store.js +370 -0
- package/dist/daemon-supervisor.js +460 -0
- package/dist/env/onboard-draft-store.js +250 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +205 -0
- package/dist/events-cloud-tail.js +146 -0
- package/dist/events-cloud.js +619 -0
- package/dist/events.js +186 -31
- package/dist/oauth.js +129 -34
- package/dist/onboard-accounts.js +259 -0
- package/dist/onboard-automation-management.js +15 -0
- package/dist/onboard-bootstrap.js +162 -0
- package/dist/onboard-capabilities.js +15 -7
- package/dist/onboard-capacity.js +226 -0
- package/dist/onboard-existing.js +106 -32
- package/dist/onboard-file-snapshot.js +45 -0
- package/dist/onboard-github.js +14 -3
- package/dist/onboard-login-candidate.js +261 -0
- package/dist/onboard-next.js +139 -0
- package/dist/onboard-personal.js +14 -3
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +146 -37
- package/dist/onboard-runtime.js +108 -13
- package/dist/onboard-settings-context.js +141 -0
- package/dist/onboard-settings-import.js +127 -0
- package/dist/onboard-settings.js +234 -0
- package/dist/onboard-ui.js +85 -59
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard-workspace.js +14 -3
- package/dist/onboard.js +422 -39
- package/dist/owned-fetch.js +182 -0
- package/dist/pagination.js +13 -1
- package/dist/query.js +104 -31
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/repository-id.js +5 -0
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +4 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- package/skills/catalyst-github/references/local-replica.md +78 -0
- package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
- package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
- package/skills/catalyst-linear/SKILL.md +5 -3
- package/skills/catalyst-linear/references/local-replica.md +91 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +6 -4
- package/skills/catalyst-onboard/references/local-replica.md +40 -0
- package/skills/catalyst-onboard/references/local-sync.md +8 -4
- package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +4 -2
- package/skills/whats-happening/references/local-replica.md +51 -0
- package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
- package/skills/whats-happening/references/status-reply.md +1 -1
- package/skills/whats-happening/scripts/snapshot.mjs +104 -26
- package/vendor/settings/index.d.ts +4 -0
- package/vendor/settings/index.js +2937 -0
- package/vendor/settings/provenance.json +148 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
const fail = () => new Error("daemon_cache_identity_unverified");
|
|
5
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
6
|
+
const own = (s) => process.getuid === undefined || s.uid === process.getuid();
|
|
7
|
+
const sameInode = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
8
|
+
const validId = (s) => typeof s === "string" &&
|
|
9
|
+
s.length > 0 &&
|
|
10
|
+
s.length <= 256 &&
|
|
11
|
+
!Array.from(s).some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
|
|
12
|
+
function permissions(value) {
|
|
13
|
+
if (value === null)
|
|
14
|
+
return null;
|
|
15
|
+
if (!Array.isArray(value) ||
|
|
16
|
+
value.length > 128 ||
|
|
17
|
+
value.some((v) => !validId(v)))
|
|
18
|
+
throw fail();
|
|
19
|
+
return [...new Set(value)].sort();
|
|
20
|
+
}
|
|
21
|
+
function httpsOrigin(value) {
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(value);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw fail();
|
|
28
|
+
}
|
|
29
|
+
if (url.protocol !== "https:" ||
|
|
30
|
+
url.username ||
|
|
31
|
+
url.password ||
|
|
32
|
+
url.search ||
|
|
33
|
+
url.hash ||
|
|
34
|
+
(url.pathname !== "/" && url.pathname !== ""))
|
|
35
|
+
throw fail();
|
|
36
|
+
return url.origin;
|
|
37
|
+
}
|
|
38
|
+
/** Only call after a fresh authenticated /me read. Config labels and token claims are not proof. */
|
|
39
|
+
export function personCacheScope(config, me) {
|
|
40
|
+
if (!me.user ||
|
|
41
|
+
!config.user ||
|
|
42
|
+
!validId(me.account) ||
|
|
43
|
+
!validId(me.user.id) ||
|
|
44
|
+
me.account !== config.account ||
|
|
45
|
+
me.user.id !== config.user.id ||
|
|
46
|
+
me.user.role !== config.user.role ||
|
|
47
|
+
!["owner", "admin", "member"].includes(me.user.role))
|
|
48
|
+
throw fail();
|
|
49
|
+
// /me does not carry project ACL revision or a personal replica dataset scope.
|
|
50
|
+
// Account-wide warm caches are therefore limited to current owners and admins.
|
|
51
|
+
if (me.user.role === "member")
|
|
52
|
+
throw new Error("daemon_cache_member_scope_unavailable");
|
|
53
|
+
const currentPermissions = permissions(me.permissions);
|
|
54
|
+
if (currentPermissions !== null &&
|
|
55
|
+
!currentPermissions.includes("mirror:read"))
|
|
56
|
+
throw fail();
|
|
57
|
+
if (JSON.stringify(currentPermissions) !==
|
|
58
|
+
JSON.stringify(permissions(config.permissions)))
|
|
59
|
+
throw fail();
|
|
60
|
+
if (Boolean(config.key) === Boolean(config.auth) ||
|
|
61
|
+
me.principal !== config.principal)
|
|
62
|
+
throw fail();
|
|
63
|
+
let credential;
|
|
64
|
+
if (config.key) {
|
|
65
|
+
if (me.principal !== "service" ||
|
|
66
|
+
!config.key.startsWith("ctc_user_") ||
|
|
67
|
+
config.key.length > 8192)
|
|
68
|
+
throw fail();
|
|
69
|
+
credential = "personal-key:" + digest(config.key);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// Device-code JWTs are user-bearing service principals; session denotes browser cookies.
|
|
73
|
+
if (me.principal !== "service" ||
|
|
74
|
+
!config.auth ||
|
|
75
|
+
config.auth.kind !== "oauth" ||
|
|
76
|
+
!validId(config.auth.sessionId))
|
|
77
|
+
throw fail();
|
|
78
|
+
credential = "oauth-session:" + digest(config.auth.sessionId);
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
schema: 1,
|
|
82
|
+
account: me.account,
|
|
83
|
+
person: me.user.id,
|
|
84
|
+
origin: httpsOrigin(config.baseUrl),
|
|
85
|
+
role: me.user.role,
|
|
86
|
+
permissions: currentPermissions === null ? null : Object.freeze(currentPermissions),
|
|
87
|
+
credential,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function canonical(scope) {
|
|
91
|
+
if (scope.schema !== 1 ||
|
|
92
|
+
!validId(scope.account) ||
|
|
93
|
+
!validId(scope.person) ||
|
|
94
|
+
!["owner", "admin", "member"].includes(scope.role) ||
|
|
95
|
+
httpsOrigin(scope.origin) !== scope.origin ||
|
|
96
|
+
!/^(personal-key|oauth-session):[a-f0-9]{64}$/.test(scope.credential))
|
|
97
|
+
throw fail();
|
|
98
|
+
if (scope.role === "member")
|
|
99
|
+
throw new Error("daemon_cache_member_scope_unavailable");
|
|
100
|
+
const granted = permissions(scope.permissions);
|
|
101
|
+
if (granted !== null && !granted.includes("mirror:read"))
|
|
102
|
+
throw fail();
|
|
103
|
+
return (JSON.stringify({
|
|
104
|
+
schema: 1,
|
|
105
|
+
account: scope.account,
|
|
106
|
+
person: scope.person,
|
|
107
|
+
origin: scope.origin,
|
|
108
|
+
role: scope.role,
|
|
109
|
+
permissions: permissions(scope.permissions),
|
|
110
|
+
credential: scope.credential,
|
|
111
|
+
}) + "\n");
|
|
112
|
+
}
|
|
113
|
+
function stat(path) {
|
|
114
|
+
try {
|
|
115
|
+
return lstatSync(path);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
119
|
+
return null;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function checkDirectory(path, privateMode) {
|
|
124
|
+
const s = lstatSync(path);
|
|
125
|
+
if (!s.isDirectory() ||
|
|
126
|
+
s.isSymbolicLink() ||
|
|
127
|
+
!own(s) ||
|
|
128
|
+
(s.mode & (privateMode ? 0o077 : 0o022)) !== 0)
|
|
129
|
+
throw fail();
|
|
130
|
+
return s;
|
|
131
|
+
}
|
|
132
|
+
function readWitness(path) {
|
|
133
|
+
const before = lstatSync(path);
|
|
134
|
+
if (!before.isFile() ||
|
|
135
|
+
before.isSymbolicLink() ||
|
|
136
|
+
!own(before) ||
|
|
137
|
+
before.nlink !== 1 ||
|
|
138
|
+
(before.mode & 0o077) !== 0 ||
|
|
139
|
+
before.size > 65536)
|
|
140
|
+
throw fail();
|
|
141
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
142
|
+
try {
|
|
143
|
+
const opened = fstatSync(fd);
|
|
144
|
+
if (!sameInode(before, opened))
|
|
145
|
+
throw fail();
|
|
146
|
+
const buffer = Buffer.alloc(65537);
|
|
147
|
+
let count = 0;
|
|
148
|
+
while (count < buffer.length) {
|
|
149
|
+
const read = readSync(fd, buffer, count, buffer.length - count, null);
|
|
150
|
+
if (read === 0)
|
|
151
|
+
break;
|
|
152
|
+
count += read;
|
|
153
|
+
}
|
|
154
|
+
if (count > 65536)
|
|
155
|
+
throw fail();
|
|
156
|
+
const bytes = buffer.subarray(0, count).toString("utf8");
|
|
157
|
+
const after = lstatSync(path), ended = fstatSync(fd);
|
|
158
|
+
if (!sameInode(opened, after) ||
|
|
159
|
+
!sameInode(opened, ended) ||
|
|
160
|
+
opened.size !== Buffer.byteLength(bytes) ||
|
|
161
|
+
opened.mtimeMs !== after.mtimeMs ||
|
|
162
|
+
opened.mtimeMs !== ended.mtimeMs ||
|
|
163
|
+
opened.ctimeMs !== after.ctimeMs ||
|
|
164
|
+
(after.mode & 0o077) !== 0 ||
|
|
165
|
+
!own(after) ||
|
|
166
|
+
after.nlink !== 1)
|
|
167
|
+
throw fail();
|
|
168
|
+
return { bytes, stat: after };
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
closeSync(fd);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function checkCacheFiles(directory, pinned) {
|
|
175
|
+
let observed = 0;
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
const visit = (parent, depth) => {
|
|
178
|
+
if (depth > 16)
|
|
179
|
+
throw fail();
|
|
180
|
+
for (const name of readdirSync(parent)) {
|
|
181
|
+
if (++observed > 100000)
|
|
182
|
+
throw fail();
|
|
183
|
+
const path = join(parent, name), s = lstatSync(path);
|
|
184
|
+
// The enclosing scope directory is 0700. SDK-default 0644/0755 children are
|
|
185
|
+
// inaccessible to other users; writable aliases, links and special files are refused.
|
|
186
|
+
if (s.isSymbolicLink() || !own(s) || (s.mode & 0o022) !== 0)
|
|
187
|
+
throw fail();
|
|
188
|
+
if ((path === join(directory, "replica.db") && !s.isFile()) ||
|
|
189
|
+
(path === join(directory, "events") && !s.isDirectory()))
|
|
190
|
+
throw fail();
|
|
191
|
+
if (path === join(directory, "replica.db") ||
|
|
192
|
+
path === join(directory, "events")) {
|
|
193
|
+
const before = pinned.get(path);
|
|
194
|
+
if (before && !sameInode(before, s))
|
|
195
|
+
throw fail();
|
|
196
|
+
pinned.set(path, s);
|
|
197
|
+
}
|
|
198
|
+
seen.add(path);
|
|
199
|
+
if (s.isDirectory())
|
|
200
|
+
visit(path, depth + 1);
|
|
201
|
+
else if (!s.isFile() || s.nlink !== 1)
|
|
202
|
+
throw fail();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
visit(directory, 0);
|
|
206
|
+
for (const path of pinned.keys())
|
|
207
|
+
if (!seen.has(path))
|
|
208
|
+
throw fail();
|
|
209
|
+
}
|
|
210
|
+
/** SDK 0.13.1 has no IO-settlement acknowledgement. Refuse it before any cache or writer work. */
|
|
211
|
+
export function requireAwaitedReplicaShutdown(replicaClass) {
|
|
212
|
+
if (!("closeAndWait" in replicaClass.prototype) ||
|
|
213
|
+
typeof replicaClass.prototype.closeAndWait !== "function")
|
|
214
|
+
throw new Error("daemon_sdk_shutdown_unavailable");
|
|
215
|
+
}
|
|
216
|
+
/** Creates a new person-scoped cache; never adopts legacy cfg.replicaDb or tenant event paths.
|
|
217
|
+
* assertCurrent must synchronously reject expired or changed authority before every use. These
|
|
218
|
+
* inode checks protect cooperative local use; they do not promise a filesystem CAS. */
|
|
219
|
+
export function preparePersonCache(input) {
|
|
220
|
+
requireAwaitedReplicaShutdown(input.replicaClass);
|
|
221
|
+
input.assertCurrent();
|
|
222
|
+
const bytes = canonical(input.scope);
|
|
223
|
+
if (!isAbsolute(input.home))
|
|
224
|
+
throw fail();
|
|
225
|
+
const home = resolve(input.home);
|
|
226
|
+
const directory = join(home, ".config", "catalyst-cloud", "person-cache", "v1", digest(bytes));
|
|
227
|
+
const privateRoot = join(home, ".config", "catalyst-cloud", "person-cache");
|
|
228
|
+
const paths = [];
|
|
229
|
+
for (let path = directory; path !== dirname(path); path = dirname(path))
|
|
230
|
+
paths.unshift(path);
|
|
231
|
+
const pinned = new Map();
|
|
232
|
+
for (const path of paths) {
|
|
233
|
+
input.assertCurrent();
|
|
234
|
+
const withinHome = path === home || path.startsWith(home + sep);
|
|
235
|
+
const privateMode = path === privateRoot || path.startsWith(privateRoot + sep);
|
|
236
|
+
if (!stat(path)) {
|
|
237
|
+
if (!withinHome)
|
|
238
|
+
throw fail();
|
|
239
|
+
mkdirSync(path, { mode: 0o700 });
|
|
240
|
+
}
|
|
241
|
+
// System ancestors may belong to root; they must still be real directories without links.
|
|
242
|
+
if (!withinHome) {
|
|
243
|
+
const s = lstatSync(path);
|
|
244
|
+
const trustedSticky = s.uid === 0 && (s.mode & 0o1000) !== 0;
|
|
245
|
+
if (!s.isDirectory() ||
|
|
246
|
+
s.isSymbolicLink() ||
|
|
247
|
+
(!own(s) && s.uid !== 0) ||
|
|
248
|
+
((s.mode & 0o022) !== 0 && !trustedSticky))
|
|
249
|
+
throw fail();
|
|
250
|
+
}
|
|
251
|
+
else
|
|
252
|
+
pinned.set(path, checkDirectory(path, privateMode));
|
|
253
|
+
}
|
|
254
|
+
const witnessPath = join(directory, "identity.json");
|
|
255
|
+
if (!stat(witnessPath)) {
|
|
256
|
+
if (readdirSync(directory).length !== 0)
|
|
257
|
+
throw fail();
|
|
258
|
+
input.assertCurrent();
|
|
259
|
+
const fd = openSync(witnessPath, constants.O_WRONLY |
|
|
260
|
+
constants.O_CREAT |
|
|
261
|
+
constants.O_EXCL |
|
|
262
|
+
constants.O_NOFOLLOW, 0o600);
|
|
263
|
+
try {
|
|
264
|
+
writeFileSync(fd, bytes);
|
|
265
|
+
fsyncSync(fd);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
closeSync(fd);
|
|
269
|
+
}
|
|
270
|
+
const directoryFd = openSync(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
271
|
+
try {
|
|
272
|
+
fsyncSync(directoryFd);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
closeSync(directoryFd);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const witness = readWitness(witnessPath);
|
|
279
|
+
if (witness.bytes !== bytes)
|
|
280
|
+
throw fail();
|
|
281
|
+
const cacheInodes = new Map();
|
|
282
|
+
const assertWitness = () => {
|
|
283
|
+
input.assertCurrent();
|
|
284
|
+
for (const [path, original] of pinned) {
|
|
285
|
+
const privateMode = path === privateRoot || path.startsWith(privateRoot + sep);
|
|
286
|
+
if (!sameInode(original, checkDirectory(path, privateMode)))
|
|
287
|
+
throw fail();
|
|
288
|
+
}
|
|
289
|
+
const current = readWitness(witnessPath);
|
|
290
|
+
if (!sameInode(witness.stat, current.stat) || current.bytes !== bytes)
|
|
291
|
+
throw fail();
|
|
292
|
+
};
|
|
293
|
+
const assertOwnedPaths = () => {
|
|
294
|
+
assertWitness();
|
|
295
|
+
for (const [path, directoryPath] of [
|
|
296
|
+
[join(directory, "replica.db"), false],
|
|
297
|
+
[join(directory, "events"), true],
|
|
298
|
+
]) {
|
|
299
|
+
let stat;
|
|
300
|
+
try {
|
|
301
|
+
stat = lstatSync(path);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error.code === "ENOENT" &&
|
|
305
|
+
!cacheInodes.has(path))
|
|
306
|
+
continue;
|
|
307
|
+
throw fail();
|
|
308
|
+
}
|
|
309
|
+
if (stat.isSymbolicLink() ||
|
|
310
|
+
!own(stat) ||
|
|
311
|
+
(stat.mode & 0o022) !== 0 ||
|
|
312
|
+
(directoryPath
|
|
313
|
+
? !stat.isDirectory()
|
|
314
|
+
: !stat.isFile() || stat.nlink !== 1))
|
|
315
|
+
throw fail();
|
|
316
|
+
const before = cacheInodes.get(path);
|
|
317
|
+
if (before && !sameInode(before, stat))
|
|
318
|
+
throw fail();
|
|
319
|
+
cacheInodes.set(path, stat);
|
|
320
|
+
}
|
|
321
|
+
// SQLite owns these transient leaves and may remove/recreate them at checkpoint/close.
|
|
322
|
+
// Reject persistent aliases before the next SQL entry without pinning or deleting them.
|
|
323
|
+
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
|
324
|
+
const leaf = stat(join(directory, "replica.db" + suffix));
|
|
325
|
+
if (leaf &&
|
|
326
|
+
(!leaf.isFile() ||
|
|
327
|
+
leaf.isSymbolicLink() ||
|
|
328
|
+
!own(leaf) ||
|
|
329
|
+
leaf.nlink !== 1 ||
|
|
330
|
+
(leaf.mode & 0o022) !== 0))
|
|
331
|
+
throw fail();
|
|
332
|
+
}
|
|
333
|
+
input.assertCurrent();
|
|
334
|
+
};
|
|
335
|
+
const assertBound = () => {
|
|
336
|
+
assertWitness();
|
|
337
|
+
checkCacheFiles(directory, cacheInodes);
|
|
338
|
+
input.assertCurrent();
|
|
339
|
+
};
|
|
340
|
+
assertBound();
|
|
341
|
+
return {
|
|
342
|
+
directory,
|
|
343
|
+
replicaDb: join(directory, "replica.db"),
|
|
344
|
+
eventDirectory: join(directory, "events"),
|
|
345
|
+
assertBound,
|
|
346
|
+
assertOwnedPaths,
|
|
347
|
+
};
|
|
348
|
+
}
|