pi-codex-marketplace 0.1.0
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/LICENSE +21 -0
- package/README.md +134 -0
- package/extensions/pi/git-registration.ts +138 -0
- package/extensions/pi/index.ts +293 -0
- package/extensions/pi/installation.ts +90 -0
- package/extensions/pi/journal.ts +80 -0
- package/extensions/pi/lifecycle.ts +285 -0
- package/extensions/pi/registration.ts +143 -0
- package/extensions/pi/scope-overrides.ts +170 -0
- package/package.json +60 -0
- package/src/barrier/global-barrier.ts +105 -0
- package/src/bridge-state/atomic.ts +237 -0
- package/src/bridge-state/index.ts +5 -0
- package/src/bridge-state/migrate.ts +261 -0
- package/src/bridge-state/paths.ts +75 -0
- package/src/bridge-state/repair.ts +185 -0
- package/src/bridge-state/schema.ts +70 -0
- package/src/bridge-state/store.ts +489 -0
- package/src/bridge-state/types.ts +170 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/paths.ts +42 -0
- package/src/cache/source-cache.ts +365 -0
- package/src/compatibility/index.ts +1 -0
- package/src/compatibility/profile.ts +328 -0
- package/src/installation/flow.ts +443 -0
- package/src/installation/index.ts +1 -0
- package/src/installation/inspection.ts +129 -0
- package/src/journal/active-chains.ts +99 -0
- package/src/journal/index.ts +3 -0
- package/src/journal/journal.ts +215 -0
- package/src/journal/types.ts +49 -0
- package/src/lifecycle/index.ts +5 -0
- package/src/lifecycle/rebind.ts +290 -0
- package/src/lifecycle/refresh.ts +407 -0
- package/src/lifecycle/removal.ts +457 -0
- package/src/lifecycle/update-plan.ts +222 -0
- package/src/lifecycle/update.ts +303 -0
- package/src/projection/collision.ts +120 -0
- package/src/projection/effective-state.ts +182 -0
- package/src/projection/index.ts +4 -0
- package/src/projection/overrides.ts +230 -0
- package/src/projection/project.ts +359 -0
- package/src/reconciliation/startup.ts +144 -0
- package/src/registration/budget.ts +28 -0
- package/src/registration/catalog.ts +224 -0
- package/src/registration/contained.ts +140 -0
- package/src/registration/fence.ts +86 -0
- package/src/registration/findings.ts +188 -0
- package/src/registration/flow.ts +619 -0
- package/src/registration/git-acquisition.ts +481 -0
- package/src/registration/git-flow.ts +654 -0
- package/src/registration/git-locator.ts +380 -0
- package/src/registration/git-selector.ts +279 -0
- package/src/registration/index.ts +16 -0
- package/src/registration/receipt.ts +305 -0
- package/src/registration/registration.ts +102 -0
- package/src/registration/snapshot.ts +382 -0
- package/src/registration/source-key.ts +111 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation Snapshot — the immutable source state to which validation and confirmation apply.
|
|
3
|
+
* See CONTEXT.md: Validation Snapshot.
|
|
4
|
+
*
|
|
5
|
+
* Covers the complete inspected tree: ordered paths, object types, modes, symlink targets, content
|
|
6
|
+
* hashes. Binds the Source Key, Compatibility Profile, Validation Ruleset, and Validation Budget.
|
|
7
|
+
* For Git, also binds Canonical Git Locator and Resolved Revision.
|
|
8
|
+
* The fingerprint must still match before durable state mutation; a mismatch is a Blocking Finding.
|
|
9
|
+
*
|
|
10
|
+
* Deterministic: entries are always ordered by relative path; the fingerprint is a sha256 over the
|
|
11
|
+
* canonical entry list plus binds.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import {
|
|
16
|
+
lstatSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
readlinkSync,
|
|
20
|
+
realpathSync,
|
|
21
|
+
statSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { join, sep } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { BUDGET, COMPATIBILITY_PROFILE, VALIDATION_BUDGET, VALIDATION_RULESET } from './budget.js';
|
|
26
|
+
import { CODE, RULE, blocking, type ValidationFinding } from './findings.js';
|
|
27
|
+
import type { SourceKey } from './source-key.js';
|
|
28
|
+
import type { Scope } from '../bridge-state/types.js';
|
|
29
|
+
|
|
30
|
+
export type SnapshotEntryType = 'file' | 'dir' | 'symlink';
|
|
31
|
+
|
|
32
|
+
export interface SnapshotEntry {
|
|
33
|
+
/** Posix relative path from the Marketplace Root. */
|
|
34
|
+
relPath: string;
|
|
35
|
+
type: SnapshotEntryType;
|
|
36
|
+
/** Permission bits (file mode). */
|
|
37
|
+
mode: number;
|
|
38
|
+
/** Byte size (files). */
|
|
39
|
+
size: number;
|
|
40
|
+
/** Symlink target text (symlinks), else undefined. */
|
|
41
|
+
symlinkTarget?: string;
|
|
42
|
+
/** sha256 content hash (files), else undefined. */
|
|
43
|
+
contentHash?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ValidationSnapshot {
|
|
47
|
+
/** sha256 hex over ordered entries + binds. */
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
scope: Scope;
|
|
50
|
+
/** Ordered (sorted) inspected tree entries. */
|
|
51
|
+
entries: SnapshotEntry[];
|
|
52
|
+
/** Bound Source Key (local canonical real path for #17, git canonicalUrl+selector for #18). */
|
|
53
|
+
sourceKey: SourceKey;
|
|
54
|
+
profile: string;
|
|
55
|
+
ruleset: string;
|
|
56
|
+
budget: string;
|
|
57
|
+
/** Git-only: Canonical Git Locator bound at validation time */
|
|
58
|
+
canonicalLocator?: string;
|
|
59
|
+
/** Git-only: Resolved Revision (full commit) bound at validation time */
|
|
60
|
+
resolvedRevision?: string;
|
|
61
|
+
/** Git-only: canonical selector string */
|
|
62
|
+
selectorCanonical?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bind material captured while deriving a disclosure into the Validation Snapshot.
|
|
67
|
+
* The base tree fingerprint remains available to registration-drift checks, while the
|
|
68
|
+
* activation snapshot also proves the exact catalog/profile bytes observed by preflight.
|
|
69
|
+
*/
|
|
70
|
+
export function bindCapturedMaterial(snapshot: ValidationSnapshot, captureFingerprint: string): ValidationSnapshot {
|
|
71
|
+
const fingerprint = createHash('sha256')
|
|
72
|
+
.update(snapshot.fingerprint)
|
|
73
|
+
.update('\u001f')
|
|
74
|
+
.update(captureFingerprint)
|
|
75
|
+
.digest('hex');
|
|
76
|
+
return { ...snapshot, fingerprint };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SnapshotResult {
|
|
80
|
+
ok: boolean;
|
|
81
|
+
snapshot?: ValidationSnapshot;
|
|
82
|
+
findings: ValidationFinding[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hashEntry(e: SnapshotEntry): string {
|
|
86
|
+
const parts = [e.relPath, e.type, String(e.mode), String(e.size)];
|
|
87
|
+
if (e.type === 'symlink') parts.push(e.symlinkTarget ?? '');
|
|
88
|
+
if (e.type === 'file') parts.push(e.contentHash ?? '');
|
|
89
|
+
return parts.join('\u001f');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fingerprintOf(entries: SnapshotEntry[], binds: string[]): string {
|
|
93
|
+
const h = createHash('sha256');
|
|
94
|
+
for (const e of entries) h.update(hashEntry(e));
|
|
95
|
+
h.update('\u001e');
|
|
96
|
+
for (const b of binds) h.update(b + '\u001f');
|
|
97
|
+
return h.digest('hex');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const IGNORE_DIRS = new Set(['.git', 'node_modules']);
|
|
101
|
+
// Rationale: a local Marketplace Root that is a git clone must not let the Bridge's own VCS
|
|
102
|
+
// metadata (.git) or vendored dependency trees (node_modules) churn the fingerprint; the
|
|
103
|
+
// snapshot still records the directory entries themselves at their own depth. Full snapshot
|
|
104
|
+
// semantics over these dirs is revisited with Source Drift (#22).
|
|
105
|
+
|
|
106
|
+
function walkTree(
|
|
107
|
+
canonicalRoot: string,
|
|
108
|
+
scope: Scope,
|
|
109
|
+
skip: Set<string>,
|
|
110
|
+
findings: ValidationFinding[],
|
|
111
|
+
entries: SnapshotEntry[],
|
|
112
|
+
): { fileCount: number; totalBytes: number; budgetBlocked: boolean } {
|
|
113
|
+
let fileCount = 0;
|
|
114
|
+
let totalBytes = 0;
|
|
115
|
+
let budgetBlocked = false;
|
|
116
|
+
|
|
117
|
+
const failBudget = (msg: string) => {
|
|
118
|
+
budgetBlocked = true;
|
|
119
|
+
findings.push(
|
|
120
|
+
blocking({
|
|
121
|
+
code: CODE.BUDGET_EXCEEDED,
|
|
122
|
+
phase: 'validation',
|
|
123
|
+
target: 'source',
|
|
124
|
+
scope,
|
|
125
|
+
pointer: '/',
|
|
126
|
+
rule: RULE.BUDGET_EXCEEDED,
|
|
127
|
+
outcome: msg,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const walk = (dir: string, depth: number, stack: string[]): void => {
|
|
133
|
+
if (budgetBlocked) return;
|
|
134
|
+
if (depth > BUDGET.maxTreeDepth) {
|
|
135
|
+
failBudget(`Validation Budget exceeded: tree depth ${depth} > ${BUDGET.maxTreeDepth}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let names: string[];
|
|
139
|
+
try {
|
|
140
|
+
names = readdirSync(dir);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
const err = e as NodeJS.ErrnoException;
|
|
143
|
+
if (err.code === 'ENOENT') return;
|
|
144
|
+
failBudget(`unable to read directory: ${err.message}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
names.sort();
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
if (budgetBlocked) return;
|
|
150
|
+
const abs = join(dir, name);
|
|
151
|
+
const rel = [...stack, name].join('/');
|
|
152
|
+
let lst;
|
|
153
|
+
try {
|
|
154
|
+
lst = lstatSync(abs);
|
|
155
|
+
} catch {
|
|
156
|
+
continue; // vanished during walk (or broken entry)
|
|
157
|
+
}
|
|
158
|
+
if (lst.isSymbolicLink()) {
|
|
159
|
+
let target = '';
|
|
160
|
+
try {
|
|
161
|
+
target = readlinkSync(abs);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
const err = e as NodeJS.ErrnoException;
|
|
164
|
+
findings.push(
|
|
165
|
+
blocking({
|
|
166
|
+
code: CODE.CONTAINED_SYMLINK_VIOLATION,
|
|
167
|
+
phase: 'validation',
|
|
168
|
+
target: 'source',
|
|
169
|
+
scope,
|
|
170
|
+
pointer: rel,
|
|
171
|
+
rule: RULE.CONTAINED_SYMLINK_VIOLATION,
|
|
172
|
+
outcome: `broken symlink: ${err.message}`,
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
entries.push({ relPath: rel, type: 'symlink', mode: lst.mode, size: lst.size, symlinkTarget: target });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (lst.isDirectory()) {
|
|
181
|
+
entries.push({ relPath: rel, type: 'dir', mode: lst.mode, size: lst.size });
|
|
182
|
+
if (!skip.has(name)) walk(abs, depth + 1, [...stack, name]);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!lst.isFile()) {
|
|
186
|
+
// special files are recorded but not hashed
|
|
187
|
+
entries.push({ relPath: rel, type: 'file', mode: lst.mode, size: lst.size });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
fileCount += 1;
|
|
191
|
+
if (fileCount > BUDGET.maxFiles) {
|
|
192
|
+
failBudget(`Validation Budget exceeded: ${fileCount} files > ${BUDGET.maxFiles}`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
let st: ReturnType<typeof statSync>;
|
|
196
|
+
try {
|
|
197
|
+
st = statSync(abs);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
const err = e as NodeJS.ErrnoException;
|
|
200
|
+
if (err.code === 'ENOENT') continue;
|
|
201
|
+
failBudget(`stat error: ${err.message}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
totalBytes += st.size;
|
|
205
|
+
if (totalBytes > BUDGET.maxTotalBytes) {
|
|
206
|
+
failBudget(`Validation Budget exceeded: ${totalBytes} bytes > ${BUDGET.maxTotalBytes}`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
let contentHash = '';
|
|
210
|
+
try {
|
|
211
|
+
contentHash = createHash('sha256').update(readFileSync(abs)).digest('hex');
|
|
212
|
+
} catch (e) {
|
|
213
|
+
const err = e as NodeJS.ErrnoException;
|
|
214
|
+
if (err.code === 'ENOENT') continue;
|
|
215
|
+
failBudget(`unable to hash content: ${err.message}`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
entries.push({ relPath: rel, type: 'file', mode: lst.mode, size: st.size, contentHash });
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
walk(canonicalRoot, 1, []);
|
|
223
|
+
return { fileCount, totalBytes, budgetBlocked };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function checkSymlinkContainment(canonicalRoot: string, entries: SnapshotEntry[], scope: Scope, findings: ValidationFinding[]): void {
|
|
227
|
+
for (const e of entries) {
|
|
228
|
+
if (e.type !== 'symlink') continue;
|
|
229
|
+
const abs = join(canonicalRoot, ...e.relPath.split('/'));
|
|
230
|
+
let canonical = '';
|
|
231
|
+
try {
|
|
232
|
+
canonical = realpathSync.native(abs);
|
|
233
|
+
} catch {
|
|
234
|
+
findings.push(
|
|
235
|
+
blocking({
|
|
236
|
+
code: CODE.CONTAINED_SYMLINK_VIOLATION,
|
|
237
|
+
phase: 'validation',
|
|
238
|
+
target: 'source',
|
|
239
|
+
scope,
|
|
240
|
+
pointer: e.relPath,
|
|
241
|
+
rule: RULE.CONTAINED_SYMLINK_VIOLATION,
|
|
242
|
+
outcome: `broken or looping symlink: '${e.relPath}' has no resolvable canonical target`,
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const prefix = canonicalRoot.endsWith(sep) ? canonicalRoot : canonicalRoot + sep;
|
|
248
|
+
if (canonical !== canonicalRoot && !canonical.startsWith(prefix)) {
|
|
249
|
+
findings.push(
|
|
250
|
+
blocking({
|
|
251
|
+
code: CODE.CONTAINED_SYMLINK_VIOLATION,
|
|
252
|
+
phase: 'validation',
|
|
253
|
+
target: 'source',
|
|
254
|
+
scope,
|
|
255
|
+
pointer: e.relPath,
|
|
256
|
+
rule: RULE.CONTAINED_SYMLINK_VIOLATION,
|
|
257
|
+
outcome: `symlink target '${canonical}' escapes owning root`,
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Build a Validation Snapshot for a local Marketplace Root (canonical real path).
|
|
266
|
+
* Walks the complete tree without following symlinks; symlink targets are recorded, not descended.
|
|
267
|
+
* Budget limits produce Blocking Findings at the source boundary.
|
|
268
|
+
*/
|
|
269
|
+
export function buildLocalSnapshot(
|
|
270
|
+
canonicalRoot: string,
|
|
271
|
+
sourceKey: SourceKey,
|
|
272
|
+
scope: Scope,
|
|
273
|
+
opts: { skipDirs?: Set<string> } = {},
|
|
274
|
+
): SnapshotResult {
|
|
275
|
+
const findings: ValidationFinding[] = [];
|
|
276
|
+
const entries: SnapshotEntry[] = [];
|
|
277
|
+
const skip = opts.skipDirs ?? IGNORE_DIRS;
|
|
278
|
+
|
|
279
|
+
const { budgetBlocked } = walkTree(canonicalRoot, scope, skip, findings, entries);
|
|
280
|
+
|
|
281
|
+
// Sort deterministically by posix relative path.
|
|
282
|
+
entries.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
283
|
+
|
|
284
|
+
if (budgetBlocked) {
|
|
285
|
+
return { ok: false, findings };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Symlink containment: any symlink in the tree whose canonical target escapes the root — or
|
|
289
|
+
// cannot be resolved at all (broken / looping) — is a Blocking Finding (CONTEXT: Contained
|
|
290
|
+
// Symlink).
|
|
291
|
+
checkSymlinkContainment(canonicalRoot, entries, scope, findings);
|
|
292
|
+
|
|
293
|
+
const binds = [sourceKey.key, COMPATIBILITY_PROFILE, VALIDATION_RULESET, VALIDATION_BUDGET];
|
|
294
|
+
|
|
295
|
+
if (findings.some((f) => f.classification === 'blocking')) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
findings,
|
|
299
|
+
snapshot: {
|
|
300
|
+
fingerprint: fingerprintOf(entries, binds),
|
|
301
|
+
scope,
|
|
302
|
+
entries,
|
|
303
|
+
sourceKey,
|
|
304
|
+
profile: COMPATIBILITY_PROFILE,
|
|
305
|
+
ruleset: VALIDATION_RULESET,
|
|
306
|
+
budget: VALIDATION_BUDGET,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
ok: true,
|
|
313
|
+
findings,
|
|
314
|
+
snapshot: {
|
|
315
|
+
fingerprint: fingerprintOf(entries, binds),
|
|
316
|
+
scope,
|
|
317
|
+
entries,
|
|
318
|
+
sourceKey,
|
|
319
|
+
profile: COMPATIBILITY_PROFILE,
|
|
320
|
+
ruleset: VALIDATION_RULESET,
|
|
321
|
+
budget: VALIDATION_BUDGET,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Build a Validation Snapshot for a Git-acquired Marketplace Root.
|
|
328
|
+
* Same tree walk as local, but binds also include Canonical Locator and Resolved Revision
|
|
329
|
+
* (Source Key already contains canonicalUrl+selector; we bind them additionally for explicitness).
|
|
330
|
+
*/
|
|
331
|
+
export function buildGitSnapshot(
|
|
332
|
+
acquiredRoot: string,
|
|
333
|
+
sourceKey: SourceKey,
|
|
334
|
+
scope: Scope,
|
|
335
|
+
extra: { canonicalLocator: string; resolvedRevision: string; selectorCanonical: string },
|
|
336
|
+
opts: { skipDirs?: Set<string> } = {},
|
|
337
|
+
): SnapshotResult {
|
|
338
|
+
const findings: ValidationFinding[] = [];
|
|
339
|
+
const entries: SnapshotEntry[] = [];
|
|
340
|
+
const skip = opts.skipDirs ?? IGNORE_DIRS;
|
|
341
|
+
|
|
342
|
+
const { budgetBlocked } = walkTree(acquiredRoot, scope, skip, findings, entries);
|
|
343
|
+
|
|
344
|
+
entries.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
345
|
+
|
|
346
|
+
if (budgetBlocked) {
|
|
347
|
+
return { ok: false, findings };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
checkSymlinkContainment(acquiredRoot, entries, scope, findings);
|
|
351
|
+
|
|
352
|
+
const binds = [
|
|
353
|
+
sourceKey.key,
|
|
354
|
+
extra.canonicalLocator,
|
|
355
|
+
extra.resolvedRevision,
|
|
356
|
+
extra.selectorCanonical,
|
|
357
|
+
COMPATIBILITY_PROFILE,
|
|
358
|
+
VALIDATION_RULESET,
|
|
359
|
+
VALIDATION_BUDGET,
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
const fingerprint = fingerprintOf(entries, binds);
|
|
363
|
+
|
|
364
|
+
const snapshot: ValidationSnapshot = {
|
|
365
|
+
fingerprint,
|
|
366
|
+
scope,
|
|
367
|
+
entries,
|
|
368
|
+
sourceKey,
|
|
369
|
+
profile: COMPATIBILITY_PROFILE,
|
|
370
|
+
ruleset: VALIDATION_RULESET,
|
|
371
|
+
budget: VALIDATION_BUDGET,
|
|
372
|
+
canonicalLocator: extra.canonicalLocator,
|
|
373
|
+
resolvedRevision: extra.resolvedRevision,
|
|
374
|
+
selectorCanonical: extra.selectorCanonical,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (findings.some((f) => f.classification === 'blocking')) {
|
|
378
|
+
return { ok: false, findings, snapshot };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { ok: true, findings, snapshot };
|
|
382
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source Key — deterministic typed value for duplicate detection / repeated registration.
|
|
3
|
+
* See CONTEXT.md: Source Key. Not the identity of a registration.
|
|
4
|
+
*
|
|
5
|
+
* local Source Key = canonical real path of the Marketplace Root.
|
|
6
|
+
* git Source Key = canonical (credential-free) remote URL + exact selector (implemented in #18).
|
|
7
|
+
* Local and Git keys remain distinct; equal keys across scopes do not merge registrations.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
import type { CanonicalGitLocator } from './git-locator.js';
|
|
13
|
+
import type { NormalizedGitSelector } from './git-selector.js';
|
|
14
|
+
|
|
15
|
+
export type SourceKind = 'local' | 'git';
|
|
16
|
+
|
|
17
|
+
export interface SourceKey {
|
|
18
|
+
kind: SourceKind;
|
|
19
|
+
/** Deterministic, typed key value used only for comparison. */
|
|
20
|
+
key: string;
|
|
21
|
+
/** Canonical real path for local; resolved via fs.realpathSync. */
|
|
22
|
+
canonicalPath?: string;
|
|
23
|
+
/** Canonical Git URL for git */
|
|
24
|
+
canonicalUrl?: string;
|
|
25
|
+
/** Canonical Git selector (for git) */
|
|
26
|
+
selector?: string;
|
|
27
|
+
/** Resolved Revision (git, not part of identity but stored for provenance) */
|
|
28
|
+
resolvedRevision?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SourceKeyResult {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
sourceKey?: SourceKey;
|
|
34
|
+
error?: string;
|
|
35
|
+
/** Node errno code (ENOENT / ENOTDIR / …) when resolution failed. */
|
|
36
|
+
errno?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build a local Source Key from the Marketplace Root. Fails (Blocking) if path missing/not a dir. */
|
|
40
|
+
export function localSourceKey(rootPath: string): SourceKeyResult {
|
|
41
|
+
let canonical: string;
|
|
42
|
+
try {
|
|
43
|
+
canonical = realpathSync.native(rootPath);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
const err = e as NodeJS.ErrnoException;
|
|
46
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
47
|
+
return { ok: false, error: `Unable to resolve Marketplace Root real path: ${msg}`, errno: err.code };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let isDir = false;
|
|
51
|
+
let errno: string | undefined;
|
|
52
|
+
try {
|
|
53
|
+
isDir = statSync(canonical).isDirectory();
|
|
54
|
+
} catch (e) {
|
|
55
|
+
const err = e as NodeJS.ErrnoException;
|
|
56
|
+
errno = err.code;
|
|
57
|
+
isDir = false;
|
|
58
|
+
}
|
|
59
|
+
if (!isDir) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: `Marketplace Root is not a directory: ${canonical}`,
|
|
63
|
+
errno,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
sourceKey: {
|
|
70
|
+
kind: 'local',
|
|
71
|
+
key: `local:${canonical}`,
|
|
72
|
+
canonicalPath: canonical,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Build a Git Source Key from its canonical locator + exact selector (type-distinct from local). */
|
|
78
|
+
export function gitSourceKey(
|
|
79
|
+
locator: CanonicalGitLocator,
|
|
80
|
+
selector: NormalizedGitSelector,
|
|
81
|
+
): SourceKey {
|
|
82
|
+
const canonicalUrl = locator.canonicalUrl;
|
|
83
|
+
const sel = selector.canonical;
|
|
84
|
+
// Key is deterministic: git:<canonicalUrl>#<canonicalSelector>
|
|
85
|
+
// Preserve host lowercasing and path case via canonicalUrl; selector already canonical.
|
|
86
|
+
const key = `git:${canonicalUrl}#${sel}`;
|
|
87
|
+
return {
|
|
88
|
+
kind: 'git',
|
|
89
|
+
key,
|
|
90
|
+
canonicalUrl,
|
|
91
|
+
selector: sel,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Equality across source keys: same kind AND same key value.
|
|
97
|
+
* Local and Git kinds are intrinsically distinct even when a local dir is a clone of the git repo.
|
|
98
|
+
*/
|
|
99
|
+
export function sourceKeyEquals(a: SourceKey, b: SourceKey): boolean {
|
|
100
|
+
return a.kind === b.kind && a.key === b.key;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Redact secret-bearing content out of a locator string (local real paths carry no credentials). */
|
|
104
|
+
export function redactSource(source: string): string {
|
|
105
|
+
// Local real paths carry no credentials; guard against accidental '://user:pass@' for future git.
|
|
106
|
+
return source.replace(/\/\/[^/?#]+@/, (m) => {
|
|
107
|
+
// if it looks like user:pass@host, strip credentials
|
|
108
|
+
if (/\/\/[^:/\s]+:[^@\s]+@/.test(m)) return m.replace(/:[^@]*@/, ':***@');
|
|
109
|
+
return m;
|
|
110
|
+
});
|
|
111
|
+
}
|