spfn 0.3.0-beta.4 → 0.3.0-beta.6

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/dist/index.d.ts CHANGED
@@ -117,6 +117,1064 @@ interface SpfnConfig {
117
117
  deployment: DeploymentConfig;
118
118
  }
119
119
 
120
+ type KitGate = 'kit-check' | 'typecheck' | 'test' | 'build' | 'db-status' | 'health';
121
+
122
+ /**
123
+ * Turning a release's scaffold archive into a project on disk.
124
+ *
125
+ * The archive is one artifact and it is verified as one: the manifest declares
126
+ * an integrity for it, and nothing is expanded until the bytes that arrived
127
+ * match. That ordering is the whole safety property — a half-expanded archive
128
+ * that turns out to be wrong leaves files a later step has to guess about,
129
+ * while an archive rejected before expansion leaves the directory exactly as it
130
+ * was found.
131
+ *
132
+ * Two things the expansion will not do. It will not write outside the directory
133
+ * it was given, which `readTar` enforces by refusing the paths that could. And
134
+ * it will not overwrite a file that is already there: by the time the scaffold
135
+ * runs, activation has already written the licence file, and an archive that
136
+ * claims a path something else owns is a release bug, not a merge to resolve.
137
+ *
138
+ * The seed is the small, customer-owned part: the project's own name. It is
139
+ * written after expansion rather than baked into the archive so that one
140
+ * archive serves every project, and it is the only file this module edits
141
+ * rather than creates.
142
+ */
143
+
144
+ /** What the manifest says about the release's scaffold. */
145
+ interface KitScaffoldDescriptor {
146
+ recipeVersion: string;
147
+ artifact: string;
148
+ integrity: string;
149
+ }
150
+ interface ScaffoldMaterializerOptions {
151
+ artifacts: ArtifactPort;
152
+ }
153
+ /**
154
+ * The real `ScaffoldPort`: fetch, verify, expand, seed.
155
+ *
156
+ * `spfn create` is not shelled out to. A scaffold that came from a signed
157
+ * release has to be the *release's* scaffold, and a locally installed CLI
158
+ * generating its own approximation of one is how a project ends up subtly
159
+ * unlike the release it claims to be.
160
+ */
161
+ declare class ArtifactScaffoldPort implements ScaffoldPort {
162
+ private readonly artifacts;
163
+ constructor(options: ScaffoldMaterializerOptions);
164
+ createBase(request: {
165
+ targetDir: string;
166
+ name: string;
167
+ scaffold?: KitScaffoldDescriptor;
168
+ }): Promise<void>;
169
+ }
170
+
171
+ /**
172
+ * Where a Kit's local credential lives, and the two-step dance that puts it
173
+ * there (unit 06 section 3.3).
174
+ *
175
+ * The credential is stored in the OS keychain under a service of its own,
176
+ * `superfunction.spfn.kit`, so it is never reachable through the env-secret
177
+ * path that `spfn secret` uses. What is committed to the repository is only
178
+ * enough to *find* the item again — kit ID, activation ID, client ID — never
179
+ * the value and never the expiry, which changes on every rotation and would
180
+ * make the committed file wrong minutes after it was written.
181
+ *
182
+ * The dance matters. A first activation has no activation ID yet, so the CLI
183
+ * generates the credential locally, writes it under a `pending` account, and
184
+ * only then calls activation. If the response is lost in the network the same
185
+ * pending value re-drives the same idempotent call, and a second slot is never
186
+ * consumed. Once the server answers, the item moves to its final account. If
187
+ * the license is refused, the pending item is deleted.
188
+ */
189
+
190
+ interface KitCredentialRecord {
191
+ /** The opaque local credential the control plane issued or accepted. */
192
+ credential: string;
193
+ /** When registry access from this credential stops working. */
194
+ accessExpiresAt: string;
195
+ /** Server generation. A lower one than the server's is stale. */
196
+ generation: number;
197
+ }
198
+ interface KitCredentialIdentity {
199
+ kitId: string;
200
+ localClientId: string;
201
+ /** Present once the server has answered an activation. */
202
+ activationId?: string;
203
+ /** Present only while the first activation is still in flight. */
204
+ installationId?: string;
205
+ }
206
+ /** Identity of the item held while a first activation is in flight. */
207
+ type PendingIdentity = Required<Pick<KitCredentialIdentity, 'kitId' | 'installationId' | 'localClientId'>>;
208
+ /** Identity of the settled item, once the server has answered. */
209
+ type SettledIdentity = Required<Pick<KitCredentialIdentity, 'kitId' | 'activationId' | 'localClientId'>>;
210
+ interface KitCredentialStore {
211
+ readonly id: string;
212
+ isAvailable(): Promise<boolean>;
213
+ readPending(identity: PendingIdentity): Promise<KitCredentialRecord | null>;
214
+ savePending(identity: PendingIdentity, record: KitCredentialRecord): Promise<void>;
215
+ /** Move the pending item to its final account. Returns what was moved. */
216
+ promote(identity: Required<KitCredentialIdentity>, record?: KitCredentialRecord): Promise<KitCredentialRecord>;
217
+ read(identity: SettledIdentity): Promise<KitCredentialRecord | null>;
218
+ save(identity: SettledIdentity, record: KitCredentialRecord): Promise<void>;
219
+ remove(account: string): Promise<void>;
220
+ }
221
+
222
+ /** A public key the CLI is willing to trust, as base64 SPKI DER. */
223
+ interface TrustedKey {
224
+ keyId: string;
225
+ publicKey: string;
226
+ }
227
+
228
+ /**
229
+ * The first thing `spfn kit install` does, and the only place a setup URL is
230
+ * turned into something the CLI will act on.
231
+ *
232
+ * Unit 06 section 3.1: the setup link is a public locator, not a secret. That
233
+ * cuts both ways — because it is public it may be shared, and because it is
234
+ * public it must carry nothing else, so a URL with a query or a fragment is
235
+ * refused before a single byte is fetched. The origin is an exact allowlist:
236
+ * a valid signature on a descriptor served from somewhere else does not make
237
+ * that somewhere else an official setup origin.
238
+ *
239
+ * Order matters here, and it is the order below: allowlist, fetch, signature,
240
+ * schema, self-consistency, freshness, payload digest, CLI compatibility. Each
241
+ * step may only trust what the steps above it have already established.
242
+ */
243
+
244
+ /** Fetches one URL and returns the parsed JSON body, or the redirect target. */
245
+ interface SetupFetchResult {
246
+ /** Set when the origin answered with a redirect. */
247
+ redirectTo?: string;
248
+ body?: unknown;
249
+ }
250
+ type SetupFetcher = (url: string) => Promise<SetupFetchResult>;
251
+
252
+ /**
253
+ * Everything a Kit operation needs from the outside world, as one set of
254
+ * interfaces.
255
+ *
256
+ * The operations in `operations/` contain the order of the steps, the
257
+ * checkpoints and the refusals — the parts that are the same whatever is on the
258
+ * other end. The ports are the parts that are not: a control plane, a registry,
259
+ * a package manager, a database, Git, a cloud provider.
260
+ *
261
+ * The reason for the seam is not testability alone. It is that a Wave-1 install
262
+ * has to be provable *before* any of those services may be touched: with fake
263
+ * ports the whole sequence, including its failures and resumes, runs on a
264
+ * temporary directory and reaches nothing real.
265
+ *
266
+ * Two rules hold across every port:
267
+ * - a port reports outcomes, it does not decide policy. `activate` says
268
+ * `project-limit`; whether that is fatal is the operation's call;
269
+ * - a secret crosses a port boundary only as a value the CLI immediately puts
270
+ * into a keychain item or a child environment. No port writes one to disk.
271
+ */
272
+
273
+ interface KitClock {
274
+ /** An ISO-8601 instant with second precision, matching the contract. */
275
+ now(): string;
276
+ }
277
+ /**
278
+ * Transport for the signed documents. Each call returns the *signed wrapper*,
279
+ * never a document the transport has already decided to trust — verification is
280
+ * the CLI's job and stays in one place.
281
+ */
282
+ interface CatalogPort {
283
+ fetchSignedCatalog(url: string): Promise<unknown>;
284
+ fetchSignedManifest(url: string): Promise<unknown>;
285
+ }
286
+ interface ActivationRequest {
287
+ kitId: string;
288
+ installationId: string;
289
+ localClientId: string;
290
+ /** Read from a masked prompt or stdin, held in memory, never stored. */
291
+ licenseKey: string;
292
+ /** The client-generated credential already parked in the pending item. */
293
+ candidateCredential: string;
294
+ }
295
+ interface ActivationResult {
296
+ status: 'activated' | 'license-invalid' | 'license-revoked' | 'project-limit' | 'unavailable';
297
+ activationId?: string;
298
+ accessExpiresAt?: string;
299
+ generation?: number;
300
+ /** Secret-free detail for the report, e.g. how many slots are in use. */
301
+ detail?: Record<string, string | number | boolean | null>;
302
+ }
303
+ interface EntitlementResult {
304
+ entitled: boolean;
305
+ reason?: 'expired' | 'not-entitled' | 'revoked' | 'unavailable';
306
+ }
307
+ interface EntitlementRequest {
308
+ activationId: string;
309
+ kitId: string;
310
+ release: string;
311
+ /**
312
+ * The package whose availability answers the question, from the release
313
+ * that is being asked about.
314
+ *
315
+ * A generic CLI cannot know what a Kit ships as. Guessing it from the Kit
316
+ * id works only for Kits whose package happens to be named after them, and
317
+ * a wrong guess is answered by the registry as "not entitled" — a refusal
318
+ * the customer reads as a licence problem when nothing about the licence
319
+ * is wrong. The manifest names its packages, so the caller that holds the
320
+ * manifest says which one to ask about, and this stays a transport.
321
+ */
322
+ packageName?: string;
323
+ }
324
+ /**
325
+ * The answer to "please e-mail this activation a recovery challenge".
326
+ *
327
+ * `sent` whether or not the activation exists. The control plane answers the
328
+ * same way in both cases on purpose (unit 04 §6.1), so a caller who could tell
329
+ * the difference would be telling an attacker which public activation ids are
330
+ * real. The CLI carries that property forward rather than trying to be more
331
+ * helpful than the service it asked.
332
+ */
333
+ interface RecoveryRequestResult {
334
+ status: 'sent' | 'unavailable';
335
+ detail?: Record<string, string | number | boolean | null>;
336
+ }
337
+ interface RecoveryCompletionResult {
338
+ status: 'recovered' | 'recovery-invalid' | 'license-revoked' | 'unavailable';
339
+ /** The activation the replacement credential now belongs to. */
340
+ activationId?: string;
341
+ /** The server's name for the client the replacement created. */
342
+ localClientId?: string;
343
+ accessExpiresAt?: string;
344
+ generation?: number;
345
+ detail?: Record<string, string | number | boolean | null>;
346
+ }
347
+ interface LicensePort {
348
+ activate(request: ActivationRequest): Promise<ActivationResult>;
349
+ entitlement(request: EntitlementRequest): Promise<EntitlementResult>;
350
+ /** Unit 06 §4.10 step 1: ask for a challenge at the address of record. */
351
+ requestRecovery(request: {
352
+ activationId: string;
353
+ }): Promise<RecoveryRequestResult>;
354
+ /**
355
+ * Unit 06 §4.10 steps 4–5.
356
+ *
357
+ * The replacement credential is generated by the CLI and sent, rather than
358
+ * minted by the server and returned: a secret the server mints can be lost
359
+ * in transit, and losing the one that replaces a lost one has no next step.
360
+ */
361
+ completeRecovery(request: {
362
+ recoveryId: string;
363
+ challenge: string;
364
+ replacementCredential: string;
365
+ }): Promise<RecoveryCompletionResult>;
366
+ }
367
+ interface RegistrySession {
368
+ status: 'ok' | 'credential-stale' | 'credential-invalid' | 'unavailable';
369
+ /** Short-lived bearer, handed only to a child environment. */
370
+ token?: string;
371
+ expiresInSeconds?: number;
372
+ }
373
+ interface RegistryPort {
374
+ /**
375
+ * `kitId` names the keychain item the credential lives in, so a port that
376
+ * rotates an expired credential can put the replacement back where the next
377
+ * command will look for it. A port that never rotates ignores it.
378
+ */
379
+ issueSession(request: {
380
+ kitId?: string;
381
+ activationId: string;
382
+ localClientId: string;
383
+ credential: string;
384
+ /**
385
+ * Set on a retry after the registry refused the last credential. The
386
+ * local record can look perfectly current while the server has already
387
+ * stopped honouring it, and re-presenting it would fail identically.
388
+ */
389
+ forceRotation?: boolean;
390
+ }): Promise<RegistrySession>;
391
+ }
392
+ interface PackageInstallResult {
393
+ ok: boolean;
394
+ exitCode: number;
395
+ /** Why it failed, in terms the operation can branch on. */
396
+ failure?: 'unauthorized' | 'network' | 'resolution' | 'other';
397
+ }
398
+ interface PackageManagerPort {
399
+ install(request: {
400
+ cwd: string;
401
+ frozen: boolean;
402
+ env: Record<string, string>;
403
+ }): Promise<PackageInstallResult>;
404
+ }
405
+ interface DatabaseStatus {
406
+ /** Whether a connection string is configured at all. */
407
+ configured: boolean;
408
+ reachable: boolean;
409
+ applied: string[];
410
+ pending: string[];
411
+ }
412
+ interface MigrationResult {
413
+ ok: boolean;
414
+ applied: string[];
415
+ pending: string[];
416
+ backupId?: string;
417
+ failure?: string;
418
+ }
419
+ interface DatabasePort {
420
+ status(request: {
421
+ cwd: string;
422
+ }): Promise<DatabaseStatus>;
423
+ migrate(request: {
424
+ cwd: string;
425
+ withBackup: boolean;
426
+ }): Promise<MigrationResult>;
427
+ }
428
+ interface GateResult {
429
+ ok: boolean;
430
+ /** A short secret-free summary; never raw command output. */
431
+ summary?: string;
432
+ }
433
+ interface GatePort {
434
+ run(gate: KitGate, request: {
435
+ cwd: string;
436
+ }): Promise<GateResult>;
437
+ }
438
+ interface GitPort {
439
+ init(request: {
440
+ cwd: string;
441
+ }): Promise<void>;
442
+ isClean(request: {
443
+ cwd: string;
444
+ }): Promise<boolean>;
445
+ commit(request: {
446
+ cwd: string;
447
+ message: string;
448
+ }): Promise<{
449
+ commit: string;
450
+ }>;
451
+ head(request: {
452
+ cwd: string;
453
+ }): Promise<string | null>;
454
+ /**
455
+ * Which of these paths the repository actually tracks.
456
+ *
457
+ * Asked because a file that exists is not the same as a file a clean clone
458
+ * will have. `.spfn/kit-lock.json` and `.spfn/license.json` are the two
459
+ * files a restore has nothing without (unit 06 §4.2, §5.1), and a scaffold
460
+ * whose `.gitignore` covers `.spfn/` produces a project that installs,
461
+ * builds, passes every gate and cannot be restored from its own repository.
462
+ */
463
+ trackedAmong(request: {
464
+ cwd: string;
465
+ paths: readonly string[];
466
+ }): Promise<string[]>;
467
+ }
468
+ interface ScaffoldPort {
469
+ /**
470
+ * The project's base, as the release publishes it.
471
+ *
472
+ * `scaffold` is what the signed manifest declared: which archive, and the
473
+ * integrity it has to match. It is passed in rather than looked up because
474
+ * a scaffold that came from anywhere but the release under installation is
475
+ * not this release's scaffold.
476
+ */
477
+ createBase(request: {
478
+ targetDir: string;
479
+ name: string;
480
+ scaffold?: KitScaffoldDescriptor;
481
+ }): Promise<void>;
482
+ }
483
+ interface ArtifactPort {
484
+ /** The bytes of one release artifact. Verified by digest before use. */
485
+ fetch(artifact: string): Promise<Uint8Array>;
486
+ }
487
+ /** Loads a module from the installed project graph, for tooling discovery. */
488
+ type ProjectModuleLoader = (specifier: string, projectDir: string) => Promise<unknown>;
489
+ interface KitAdapters {
490
+ clock: KitClock;
491
+ setupFetcher: SetupFetcher;
492
+ trustedKeys: readonly TrustedKey[];
493
+ catalog: CatalogPort;
494
+ license: LicensePort;
495
+ registry: RegistryPort;
496
+ credentials: KitCredentialStore;
497
+ packageManager: PackageManagerPort;
498
+ database: DatabasePort;
499
+ gates: GatePort;
500
+ git: GitPort;
501
+ scaffold: ScaffoldPort;
502
+ artifacts: ArtifactPort;
503
+ loadProjectModule: ProjectModuleLoader;
504
+ /** The running public CLI version, as the descriptor compares against. */
505
+ cliVersion: string;
506
+ controlPlaneUrl: string;
507
+ registryUrl: string;
508
+ }
509
+
510
+ /**
511
+ * Where a command gets its ports from.
512
+ *
513
+ * The live set — a real control-plane client, a real registry session, the real
514
+ * package manager — arrives with the work that connects `spfn kit` to the
515
+ * running service. Until then this build has no way to reach any of it, and it
516
+ * says so with a `CLI_`-prefixed code rather than pretending to be a Kit state:
517
+ * "this CLI cannot reach the control plane" is a fact about the CLI, not about
518
+ * the project's licence, lock or release.
519
+ *
520
+ * `setKitAdapterFactory` is the seam the tests drive the whole command surface
521
+ * through, with the fake world standing in for every external service.
522
+ */
523
+
524
+ interface AdapterRequest {
525
+ projectDir: string;
526
+ }
527
+ type KitAdapterFactory = (request: AdapterRequest) => Promise<KitAdapters>;
528
+ declare function setKitAdapterFactory(next: KitAdapterFactory | null): void;
529
+
530
+ /**
531
+ * The one place `spfn kit` talks to a network, and the rules that hold there.
532
+ *
533
+ * Three of them are worth stating, because every client below depends on all
534
+ * three and none of them can be added later:
535
+ *
536
+ * - a request body may hold a licence key or a local credential, so no error
537
+ * raised here ever quotes one. What a failure is allowed to carry is the
538
+ * method, the origin, the path and the status — never the body it sent and
539
+ * never the body it got back;
540
+ * - a response is a stranger's bytes. It is read with a ceiling on its size
541
+ * and a deadline on its arrival, so an origin that answers slowly or
542
+ * endlessly stops the command instead of hanging it;
543
+ * - "the service said no" and "the service could not be reached" are
544
+ * different answers. A transport failure becomes `CLI_...`, because it is a
545
+ * fact about this machine's connection rather than about the licence.
546
+ */
547
+
548
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
549
+ interface KitHttpOptions {
550
+ /** Injected so tests reach a local fixture and nothing else. */
551
+ fetchImpl?: FetchLike;
552
+ timeoutMs?: number;
553
+ }
554
+
555
+ /**
556
+ * The real control-plane client: signed documents over HTTP, licence
557
+ * activation, and keeping this machine's local credential alive.
558
+ *
559
+ * Everything here is a translation layer and nothing here is a decision. The
560
+ * service answers in its own vocabulary — `LICENSE_REVOKED`,
561
+ * `PROJECT_LIMIT_REACHED`, `LOCAL_CREDENTIAL_STALE` — and the operations branch
562
+ * on the CLI's, so one table below maps one to the other. Where the two
563
+ * vocabularies do not line up the server's own code travels on in `detail`,
564
+ * because a report that says only "unavailable" when the server said
565
+ * `RATE_LIMITED` has thrown away the one fact that would have explained it.
566
+ *
567
+ * What this client never does is decide that a document is trustworthy. It
568
+ * returns the signed wrapper exactly as it arrived; the signature check lives
569
+ * in one place, and that place is not the transport.
570
+ */
571
+
572
+ interface ControlPlaneOptions extends KitHttpOptions {
573
+ /** Origin the licence routes are served from, e.g. `https://…`. */
574
+ baseUrl: string;
575
+ }
576
+ /** Signed catalogs and manifests, fetched and handed on unopened. */
577
+ declare class HttpCatalogPort implements CatalogPort {
578
+ private readonly http;
579
+ constructor(http?: KitHttpOptions);
580
+ fetchSignedCatalog(url: string): Promise<unknown>;
581
+ fetchSignedManifest(url: string): Promise<unknown>;
582
+ private fetchDocument;
583
+ }
584
+ /**
585
+ * Answers "does this licence still cover that release".
586
+ *
587
+ * The control plane has no endpoint for the question — entitlement is enforced
588
+ * where it is used, by the registry refusing to serve a version — so the live
589
+ * client answers it by asking the registry. The probe is injected rather than
590
+ * built in, because the licence client has no business knowing which package a
591
+ * Kit ships as.
592
+ */
593
+ type EntitlementProbe = (request: EntitlementRequest) => Promise<EntitlementResult>;
594
+ declare class HttpLicensePort implements LicensePort {
595
+ private readonly baseUrl;
596
+ private readonly http;
597
+ private readonly probe;
598
+ constructor(options: ControlPlaneOptions, probe?: EntitlementProbe | null);
599
+ activate(request: ActivationRequest): Promise<ActivationResult>;
600
+ entitlement(request: EntitlementRequest): Promise<EntitlementResult>;
601
+ requestRecovery(request: {
602
+ activationId: string;
603
+ }): Promise<RecoveryRequestResult>;
604
+ completeRecovery(request: {
605
+ recoveryId: string;
606
+ challenge: string;
607
+ replacementCredential: string;
608
+ }): Promise<RecoveryCompletionResult>;
609
+ }
610
+ /**
611
+ * The bearer the package manager installs with, and the rotation that keeps one
612
+ * available.
613
+ *
614
+ * There is no separate "session" to issue for a local install: the control
615
+ * plane accepts the machine's own local credential at the registry for as long
616
+ * as its access window is open, and issues a *new* credential when it is not.
617
+ * So this port's real job is the second half — notice a window about to close,
618
+ * rotate before it does, and put the replacement in the keychain in the same
619
+ * step, so a rotation that is not recorded can never have happened.
620
+ */
621
+ declare class HttpRegistryPort implements RegistryPort {
622
+ private readonly baseUrl;
623
+ private readonly http;
624
+ private readonly credentials;
625
+ private readonly now;
626
+ constructor(options: ControlPlaneOptions & {
627
+ credentials: KitCredentialStore;
628
+ now: () => string;
629
+ });
630
+ issueSession(request: {
631
+ kitId?: string;
632
+ activationId: string;
633
+ localClientId: string;
634
+ credential: string;
635
+ forceRotation?: boolean;
636
+ }): Promise<RegistrySession>;
637
+ private rotate;
638
+ }
639
+
640
+ /**
641
+ * The private registry, reached through the licence proxy.
642
+ *
643
+ * The proxy is the place entitlement is actually enforced: it answers with the
644
+ * versions this activation may have and rewrites every tarball link to its own
645
+ * public origin, so "which releases do I have" is a question with a real answer
646
+ * even though the licence service has no endpoint for it.
647
+ *
648
+ * Integrity is checked twice on purpose, and the two checks catch different
649
+ * things. The first compares the version's `integrity` against the digest the
650
+ * *signed manifest* pinned: it catches a registry that serves a different
651
+ * package than the release was built from, and it happens before a byte is
652
+ * downloaded. The second hashes the bytes that actually arrived: it catches a
653
+ * tarball that was replaced in transit or in storage. Passing the first says
654
+ * the registry agrees with the manifest; only the second says the file on this
655
+ * disk is the file the manifest described.
656
+ */
657
+
658
+ interface RegistryProxyOptions extends KitHttpOptions {
659
+ /** Public registry base, e.g. `https://…/npm/`. Trailing slash optional. */
660
+ registryUrl: string;
661
+ }
662
+ interface RegistryVersion {
663
+ version: string;
664
+ integrity: string;
665
+ tarball: string;
666
+ }
667
+ /**
668
+ * What one metadata request produced.
669
+ *
670
+ * `status` separates the three answers that need different handling: the
671
+ * package was served, the credential was refused, or the proxy could not say.
672
+ * "Not entitled" and "no such package" are one answer (`not-found`) because the
673
+ * proxy deliberately makes them indistinguishable.
674
+ */
675
+ interface RegistryMetadataResult {
676
+ status: 'ok' | 'not-found' | 'credential-rejected' | 'unavailable';
677
+ versions: RegistryVersion[];
678
+ detail?: string;
679
+ }
680
+ declare class KitRegistryProxyClient {
681
+ private readonly registryUrl;
682
+ private readonly http;
683
+ constructor(options: RegistryProxyOptions);
684
+ /** The entitled versions of one package, as the proxy reports them. */
685
+ metadata(packageName: string, credential: string): Promise<RegistryMetadataResult>;
686
+ /**
687
+ * The bytes of one exact version, proven against the manifest's digest.
688
+ *
689
+ * The tarball link comes from the proxy's own answer and is checked to stay
690
+ * on the registry origin: a rewritten link that points somewhere else is
691
+ * how an entitled request gets turned into a download from a stranger.
692
+ */
693
+ fetchExact(request: {
694
+ packageName: string;
695
+ version: string;
696
+ integrity: string;
697
+ credential: string;
698
+ }): Promise<Uint8Array>;
699
+ private download;
700
+ }
701
+ /** Produces the bearer a release artifact is fetched with, or null. */
702
+ type ArtifactCredentialResolver = () => Promise<string | null>;
703
+ /**
704
+ * Release artifacts — managed files, agent packs, scaffold archives — fetched
705
+ * from the release store the catalog lives in.
706
+ *
707
+ * An artifact is paid content, so the store authenticates it exactly as the npm
708
+ * proxy does: the same bearer, the same refusal codes. That makes a refusal
709
+ * here a statement about *this machine's credential* rather than about the
710
+ * file, and the two send an agent to completely different next steps — only
711
+ * one of them is `spfn kit recover`.
712
+ *
713
+ * The setup descriptor, the catalog and the manifests stay public and are
714
+ * fetched without a bearer. They are locators and promises about a release;
715
+ * nothing anyone paid for is inside them.
716
+ *
717
+ * No digest is checked here, and that is not an omission: the caller holds the
718
+ * digest the *signed manifest* declared, and checking it anywhere else would
719
+ * mean a second place that decides what "correct bytes" means.
720
+ */
721
+ declare class HttpArtifactPort implements ArtifactPort {
722
+ private readonly resolveBase;
723
+ private readonly http;
724
+ private readonly credential;
725
+ /**
726
+ * `baseUrl` may be a function because the release store is only known once
727
+ * the operation has a catalog URL — which is a fact about the release being
728
+ * installed, not about the machine installing it.
729
+ */
730
+ constructor(options: KitHttpOptions & {
731
+ baseUrl: string | (() => string);
732
+ credential?: ArtifactCredentialResolver;
733
+ });
734
+ fetch(artifact: string): Promise<Uint8Array>;
735
+ }
736
+
737
+ /**
738
+ * Proving the exact graph before installing it.
739
+ *
740
+ * A frozen install is only as exact as the registry it pulls from. The lockfile
741
+ * pins versions, the manifest pins digests, and between them sits a proxy that
742
+ * could serve a different tarball under the same name — so every package the
743
+ * signed manifest names is fetched through the proxy and checked against the
744
+ * manifest's own integrity *before* the package manager is allowed to run.
745
+ *
746
+ * This is a decorator rather than a package manager: what actually installs is
747
+ * still pnpm, npm or whatever the project uses. The verification wraps it,
748
+ * because "which packages must be exact" is a release fact and "how do I put
749
+ * them on disk" is not.
750
+ *
751
+ * The manifest is not passed in. It is read from the operation context the
752
+ * running operation already wrote, and re-verified against the trusted keys
753
+ * here — a manifest trusted because a caller handed it over is a manifest
754
+ * trusted for the wrong reason.
755
+ */
756
+
757
+ interface RegistryVerifyingPackageManagerOptions {
758
+ catalog: CatalogPort;
759
+ trustedKeys: readonly TrustedKey[];
760
+ registry: KitRegistryProxyClient;
761
+ /** What performs the install once the graph is proven. */
762
+ delegate: PackageManagerPort;
763
+ /** Package names to skip, e.g. ones the private registry does not carry. */
764
+ skip?: (name: string) => boolean;
765
+ }
766
+ declare class RegistryVerifyingPackageManager implements PackageManagerPort {
767
+ private readonly options;
768
+ constructor(options: RegistryVerifyingPackageManagerOptions);
769
+ install(request: {
770
+ cwd: string;
771
+ frozen: boolean;
772
+ env: Record<string, string>;
773
+ }): Promise<PackageInstallResult>;
774
+ private verifyGraph;
775
+ }
776
+
777
+ /**
778
+ * Assembling the half of the Kit world that lives on the network.
779
+ *
780
+ * Six ports of the ten are remote or artifact-shaped — catalog, licence,
781
+ * registry, artifacts, scaffold and the verification in front of the package
782
+ * manager — and this builds all six from three addresses. The other four
783
+ * (package manager, database, gates, Git) are local process work and are handed
784
+ * in, because how a project runs its own tests is not something a control-plane
785
+ * client should have an opinion about.
786
+ *
787
+ * The addresses are resolved per project, in one order and no other: what the
788
+ * checkout already recorded when it was activated, then what the environment
789
+ * overrides for a staging or local run, then the published defaults. A checkout
790
+ * activated against one control plane must not silently start talking to
791
+ * another because a shell variable was left set.
792
+ */
793
+
794
+ /** Where a Kit's services live when nothing says otherwise. */
795
+ declare const DEFAULT_CONTROL_PLANE_URL = "https://start.superfunction.xyz";
796
+ declare const DEFAULT_REGISTRY_URL = "https://packages.superfunction.xyz/npm/";
797
+ /** Environment overrides, for a staging control plane or a local server. */
798
+ declare const CONTROL_PLANE_URL_ENV = "SPFN_KIT_CONTROL_PLANE_URL";
799
+ declare const REGISTRY_URL_ENV = "SPFN_KIT_REGISTRY_URL";
800
+ interface KitEndpoints {
801
+ controlPlaneUrl: string;
802
+ registryUrl: string;
803
+ /** Where the addresses came from, for the status report. */
804
+ source: 'checkout' | 'environment' | 'default';
805
+ }
806
+ declare function resolveKitEndpoints(projectDir: string, env?: NodeJS.ProcessEnv): KitEndpoints;
807
+ interface KitRemotePortsOptions extends KitHttpOptions {
808
+ projectDir: string;
809
+ endpoints: KitEndpoints;
810
+ credentials: KitCredentialStore;
811
+ trustedKeys: readonly TrustedKey[];
812
+ now: () => string;
813
+ /** What actually installs, once the graph has been proven exact. */
814
+ packageManager: PackageManagerPort;
815
+ /** Packages the private registry does not serve, e.g. public ones. */
816
+ skipVerification?: (name: string) => boolean;
817
+ }
818
+ interface KitRemotePorts {
819
+ /** Fetches the setup descriptor, one allowlist-checked hop at a time. */
820
+ setupFetcher: SetupFetcher;
821
+ catalog: HttpCatalogPort;
822
+ license: HttpLicensePort;
823
+ registry: HttpRegistryPort;
824
+ artifacts: HttpArtifactPort;
825
+ scaffold: ArtifactScaffoldPort;
826
+ packageManager: RegistryVerifyingPackageManager;
827
+ /** Exposed so a caller can ask the registry a question directly. */
828
+ registryClient: KitRegistryProxyClient;
829
+ }
830
+ declare function createKitRemotePorts(options: KitRemotePortsOptions): KitRemotePorts;
831
+ /**
832
+ * A last-resort guess at the package a Kit ships as.
833
+ *
834
+ * Only for a caller with no manifest in hand. The convention it encodes —
835
+ * one scoped package named after the Kit — is not true of every Kit:
836
+ * `campaign-landing` ships as `@superfunction/landing-kit`, and asking the
837
+ * registry about `@superfunction/campaign-landing` gets a 404 that reads as
838
+ * "your licence does not cover this release". Anything that holds a manifest
839
+ * passes `packageName` instead of relying on this.
840
+ */
841
+ declare function kitPackageName(kitId: string): string;
842
+
843
+ interface RunRequest {
844
+ file: string;
845
+ args: string[];
846
+ cwd: string;
847
+ /** Secret-free variables the child needs beyond the passthrough set. */
848
+ extraEnv?: Record<string, string>;
849
+ /** The one secret a child may receive, and only through its environment. */
850
+ registryToken?: string;
851
+ /** Parent variable names this child may also read. Never secrets. */
852
+ passthrough?: readonly string[];
853
+ timeoutMs?: number;
854
+ }
855
+ interface RunResult {
856
+ exitCode: number;
857
+ stdout: string;
858
+ stderr: string;
859
+ /** True when the binary was not there at all, rather than exiting badly. */
860
+ missing: boolean;
861
+ }
862
+ type CommandRunner = (request: RunRequest) => Promise<RunResult>;
863
+
864
+ /**
865
+ * The package manager, actually run.
866
+ *
867
+ * "Frozen" is the whole point of this port. A Kit release pins an exact graph
868
+ * in a lockfile, and an install that quietly resolves something newer has
869
+ * produced a project the release never described — so the frozen flag is
870
+ * passed through to pnpm as `--frozen-lockfile`, and a lockfile that no longer
871
+ * matches `package.json` fails the install rather than being rewritten.
872
+ *
873
+ * The registry session never becomes an argument and never becomes a file. It
874
+ * reaches the child as npm configuration in that child's own environment,
875
+ * addressed to the registry it is good for — so the token is in the process's
876
+ * own memory and in no argument list, no `.npmrc` and no log. The committed
877
+ * `.npmrc` only maps the release's scopes to the registry, which is public.
878
+ *
879
+ * Why a failure is classified at all: unit 06 lets exactly one failure be
880
+ * retried with a fresh session, and only one. Everything else would fail
881
+ * identically the second time, so telling "the registry refused us" apart from
882
+ * "this graph cannot be resolved" is what keeps the retry meaningful instead of
883
+ * doubling every genuine failure.
884
+ */
885
+
886
+ interface PnpmPackageManagerOptions {
887
+ /** Registry the install resolves against. Public URL, never a secret. */
888
+ registryUrl?: string;
889
+ /**
890
+ * Secret-free variables the install needs, e.g. `npm_config_userconfig`
891
+ * pointed at an empty file so a machine's own `~/.npmrc` cannot redirect a
892
+ * scope somewhere the release never named. The operation's own environment
893
+ * takes precedence over anything here.
894
+ */
895
+ extraEnv?: Record<string, string>;
896
+ /** A store outside the user's own, for a run that must not touch it. */
897
+ storeDir?: string;
898
+ /** Refuse to run a dependency's install scripts. */
899
+ ignoreScripts?: boolean;
900
+ /** Treat the project as standalone, whatever it happens to sit inside. */
901
+ ignoreWorkspace?: boolean;
902
+ timeoutMs?: number;
903
+ /** Injected so tests drive the classification without spawning pnpm. */
904
+ run?: CommandRunner;
905
+ /** The binary to run. `pnpm` unless a test or a project says otherwise. */
906
+ binary?: string;
907
+ }
908
+ declare class PnpmPackageManagerPort implements PackageManagerPort {
909
+ private readonly options;
910
+ constructor(options?: PnpmPackageManagerOptions);
911
+ install(request: {
912
+ cwd: string;
913
+ frozen: boolean;
914
+ env: Record<string, string>;
915
+ }): Promise<PackageInstallResult>;
916
+ /**
917
+ * The arguments, with the registry among them and the session not.
918
+ *
919
+ * A registry URL is a public locator, so it is safe on a command line; the
920
+ * bearer that opens it is not, and travels in the environment instead.
921
+ */
922
+ private argumentsFor;
923
+ }
924
+
925
+ /**
926
+ * The project's database, asked through the project's own CLI.
927
+ *
928
+ * The asking is done by a child process rather than in this one, and that is
929
+ * the design rather than an accident. Reading a project's migration state
930
+ * means loading that project's environment, and loading it in *this* process
931
+ * would pull the project's secrets — a connection string with a password in it
932
+ * — into the memory of a command that has no use for them and prints reports
933
+ * for a living. A child loads them, answers one question, and exits.
934
+ *
935
+ * The question is asked in the machine-readable form `spfn db status --json`
936
+ * exists for. Nothing here parses prose: a human summary is written to change
937
+ * when it reads better, and a port that depended on its wording would break
938
+ * quietly the first time someone improved it.
939
+ */
940
+
941
+ interface SpfnDatabaseOptions {
942
+ /** How to run this CLI. Defaults to the binary this build ships with. */
943
+ command?: {
944
+ file: string;
945
+ args: string[];
946
+ };
947
+ timeoutMs?: number;
948
+ run?: CommandRunner;
949
+ }
950
+ declare class SpfnDatabasePort implements DatabasePort {
951
+ private readonly options;
952
+ constructor(options?: SpfnDatabaseOptions);
953
+ status(request: {
954
+ cwd: string;
955
+ }): Promise<DatabaseStatus>;
956
+ /**
957
+ * Apply what is waiting, then look again.
958
+ *
959
+ * The second look is not belt and braces: `spfn db migrate` reports what it
960
+ * attempted, and this port has to report what is *true afterwards*. A
961
+ * migration that exits 0 having applied three of four is a failure, and
962
+ * only re-reading the status says so.
963
+ */
964
+ migrate(request: {
965
+ cwd: string;
966
+ withBackup: boolean;
967
+ }): Promise<MigrationResult>;
968
+ private report;
969
+ private run;
970
+ }
971
+
972
+ /**
973
+ * The release's gates, run where the release lives.
974
+ *
975
+ * A gate is the release's own claim that the project it just produced works:
976
+ * it typechecks, its tests pass, it builds. So each one runs the project's own
977
+ * script wherever the project has one, and falls back to the obvious command
978
+ * only where there is an obvious one. A gate a project has no way to run is a
979
+ * failure with a reason, not a silent pass — "we could not check" and "we
980
+ * checked and it is fine" are different claims, and only one of them may let
981
+ * an install commit.
982
+ *
983
+ * `db-status` is not a command at all. It is the database port's own answer,
984
+ * asked again, because a gate that shelled out to a second implementation of
985
+ * the same question could disagree with the migration step it follows.
986
+ *
987
+ * `health` never reaches here: it is a deployment gate, and the operation
988
+ * filters it out before local gates run.
989
+ */
990
+
991
+ interface CommandGateOptions {
992
+ /** Answers the `db-status` gate. The same port the migration step used. */
993
+ database: DatabasePort;
994
+ packageManagerBinary?: string;
995
+ timeoutMs?: number;
996
+ run?: CommandRunner;
997
+ }
998
+ declare class CommandGatePort implements GatePort {
999
+ private readonly options;
1000
+ constructor(options: CommandGateOptions);
1001
+ run(gate: KitGate, request: {
1002
+ cwd: string;
1003
+ }): Promise<GateResult>;
1004
+ private commandFor;
1005
+ }
1006
+
1007
+ /**
1008
+ * Git, in the narrow shape unit 06 asks for: start a repository, say whether
1009
+ * it is clean, make one commit, name the current one.
1010
+ *
1011
+ * Deliberately nothing else. No remote is added, nothing is pushed, no branch
1012
+ * is renamed and no history is rewritten — a Kit install finishes at a
1013
+ * verified local repository, and a port that could push would make that
1014
+ * boundary a matter of trust rather than of capability.
1015
+ *
1016
+ * The identity question is handled the way it should be: the user's own
1017
+ * `user.name` and `user.email` are used whenever Git can find them, and a
1018
+ * fallback is supplied *for one command* only when it cannot. Writing an
1019
+ * identity into the repository's config would change a setting the person
1020
+ * owns; passing it with `-c` changes nothing that outlives the commit.
1021
+ */
1022
+
1023
+ interface SystemGitOptions {
1024
+ binary?: string;
1025
+ timeoutMs?: number;
1026
+ run?: CommandRunner;
1027
+ }
1028
+ declare class SystemGitPort implements GitPort {
1029
+ private readonly options;
1030
+ constructor(options?: SystemGitOptions);
1031
+ /** Start a repository, or leave the one that is already there alone. */
1032
+ init(request: {
1033
+ cwd: string;
1034
+ }): Promise<void>;
1035
+ /**
1036
+ * Whether the worktree has nothing uncommitted.
1037
+ *
1038
+ * A Git call that fails is *not* reported as clean. An update refuses to
1039
+ * start on a dirty worktree, and answering "clean" because the question
1040
+ * could not be asked would turn a missing answer into permission.
1041
+ */
1042
+ isClean(request: {
1043
+ cwd: string;
1044
+ }): Promise<boolean>;
1045
+ /**
1046
+ * Commit whatever the operation produced, or report the commit already
1047
+ * there when it produced nothing to record.
1048
+ *
1049
+ * "Nothing to commit" is not a failure. A resume reaches this after the
1050
+ * previous run committed, and an operation whose whole effect was ignored
1051
+ * by `.gitignore` reaches it too — in neither case is refusing with a raw
1052
+ * error the right answer, and the second case is caught where it belongs,
1053
+ * by the check that the committed state is actually tracked.
1054
+ */
1055
+ commit(request: {
1056
+ cwd: string;
1057
+ message: string;
1058
+ }): Promise<{
1059
+ commit: string;
1060
+ }>;
1061
+ trackedAmong(request: {
1062
+ cwd: string;
1063
+ paths: readonly string[];
1064
+ }): Promise<string[]>;
1065
+ /** The current commit, or null in a repository that has none yet. */
1066
+ head(request: {
1067
+ cwd: string;
1068
+ }): Promise<string | null>;
1069
+ /**
1070
+ * `-c user.name=… -c user.email=…`, but only when Git has neither.
1071
+ *
1072
+ * `--no-verify` above and this together mean a commit works on a machine
1073
+ * that has never been set up, without either one changing what the machine
1074
+ * does for anything else.
1075
+ */
1076
+ private identityArguments;
1077
+ private git;
1078
+ }
1079
+
1080
+ /**
1081
+ * The four ports that are local process work, assembled.
1082
+ *
1083
+ * Everything a Kit operation does on this machine rather than over the network
1084
+ * ends up here: installing the graph, applying migrations, running the
1085
+ * release's gates and making the first commit. They are built together because
1086
+ * two of them depend on a third — the `db-status` gate has to be the same
1087
+ * database port the migration step used, or a gate could contradict the step
1088
+ * it follows.
1089
+ */
1090
+
1091
+ interface KitLocalPortsOptions {
1092
+ packageManager?: PnpmPackageManagerOptions;
1093
+ database?: SpfnDatabaseOptions;
1094
+ git?: SystemGitOptions;
1095
+ gateTimeoutMs?: number;
1096
+ }
1097
+ interface KitLocalPorts {
1098
+ packageManager: PackageManagerPort;
1099
+ database: DatabasePort;
1100
+ gates: GatePort;
1101
+ git: GitPort;
1102
+ loadProjectModule: ProjectModuleLoader;
1103
+ }
1104
+ declare function createKitLocalPorts(options?: KitLocalPortsOptions): KitLocalPorts;
1105
+
1106
+ /**
1107
+ * All ten ports, for a real run.
1108
+ *
1109
+ * This is the join: the six that reach the network or a release artifact, and
1110
+ * the four that run something on this machine. Nothing here decides policy —
1111
+ * every refusal, every retry and every checkpoint still lives in the
1112
+ * operations. What this file decides is only *which* implementation answers,
1113
+ * and it makes three choices worth naming:
1114
+ *
1115
+ * - the credential store is the real keychain, under the Kit's own service.
1116
+ * A Kit credential is never reachable through the env-secret path, and the
1117
+ * separation is a constructor argument rather than a convention;
1118
+ * - the trusted keys are the ones built into this build. Nothing about a
1119
+ * project can widen them;
1120
+ * - the addresses come from the checkout first. A project activated against
1121
+ * one control plane keeps talking to that one.
1122
+ *
1123
+ * `spfn kit status` and `spfn kit check` reach this too, and must not be
1124
+ * stopped by it: they are read-only and have to work on a machine with no
1125
+ * keychain and no network. So nothing here connects, opens or checks anything
1126
+ * at construction time — every port is inert until an operation calls it.
1127
+ */
1128
+
1129
+ interface LiveKitAdapterOptions extends KitLocalPortsOptions {
1130
+ /** Overridden by tests and by an integration run against a fixture. */
1131
+ endpoints?: KitEndpoints;
1132
+ fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>;
1133
+ timeoutMs?: number;
1134
+ env?: NodeJS.ProcessEnv;
1135
+ }
1136
+ /**
1137
+ * The adapters `spfn kit` uses when nothing has replaced them.
1138
+ *
1139
+ * An ISO instant with second precision, not milliseconds: the contract's
1140
+ * timestamps are second-precision, and an operation ID derived from a
1141
+ * millisecond clock would not match the pattern the journal is checked against.
1142
+ */
1143
+ declare function createLiveKitAdapters(request: AdapterRequest, options?: LiveKitAdapterOptions): KitAdapters;
1144
+
1145
+ /**
1146
+ * The public keys this CLI will accept a signed Kit document from.
1147
+ *
1148
+ * The list is built in rather than fetched, and that is the point: a trust root
1149
+ * that arrives over the network is not a trust root, because whoever can serve
1150
+ * the document can serve the key that vouches for it. Keys change by shipping a
1151
+ * new CLI, which is a thing customers can see happen.
1152
+ *
1153
+ * The list is empty in this build. The release signing key is not published
1154
+ * yet, so every signed document fails verification, and that is the correct
1155
+ * behaviour for a CLI that has no way to tell a real release from a forged one
1156
+ * — it fails closed, with `KIT_MANIFEST_INVALID`, rather than installing
1157
+ * whatever it was handed.
1158
+ *
1159
+ * `SPFN_KIT_TRUSTED_KEYS` exists for the runs that legitimately verify against
1160
+ * a key that is not the published one: an integration against a staging
1161
+ * control plane, or a release rehearsal. It *replaces* the built-in list rather
1162
+ * than adding to it, so a stray variable can only ever narrow what this CLI
1163
+ * trusts down to keys the person running it chose.
1164
+ */
1165
+
1166
+ /** Keys shipped with this build. Empty until the signing key is published. */
1167
+ declare const BUILT_IN_TRUSTED_KEYS: readonly TrustedKey[];
1168
+ declare const TRUSTED_KEYS_ENV = "SPFN_KIT_TRUSTED_KEYS";
1169
+ /**
1170
+ * The keys to verify with, and where they came from.
1171
+ *
1172
+ * A malformed override is rejected outright rather than partially honoured: a
1173
+ * trust list that silently dropped the entry someone meant to add would verify
1174
+ * with whatever was left, which is the one outcome worth avoiding here.
1175
+ */
1176
+ declare function resolveTrustedKeys(env?: NodeJS.ProcessEnv): readonly TrustedKey[];
1177
+
120
1178
  declare function run(): Promise<void>;
121
1179
 
122
- export { type CustomDomains, type DeploymentConfig, type EnvironmentVariables, type PackageManager, type Region, type SpfnConfig, run };
1180
+ export { BUILT_IN_TRUSTED_KEYS, CONTROL_PLANE_URL_ENV, CommandGatePort, type CustomDomains, DEFAULT_CONTROL_PLANE_URL, DEFAULT_REGISTRY_URL, type DeploymentConfig, type EnvironmentVariables, type KitAdapterFactory, type KitAdapters, type KitEndpoints, type KitLocalPorts, type KitLocalPortsOptions, type KitRemotePorts, type KitRemotePortsOptions, type LiveKitAdapterOptions, type PackageManager, PnpmPackageManagerPort, REGISTRY_URL_ENV, type Region, type SpfnConfig, SpfnDatabasePort, SystemGitPort, TRUSTED_KEYS_ENV, createKitLocalPorts, createKitRemotePorts, createLiveKitAdapters, kitPackageName, resolveKitEndpoints, resolveTrustedKeys, run, setKitAdapterFactory };