@spunto/build 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/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # `@spunto/build`
2
+
3
+ The parts of [Spunto](https://spunto.net)'s **Build** pillar that are the same whoever runs the
4
+ control plane — [Spunto Cloud](https://spunto.net) and
5
+ [Spunto Lite](https://github.com/spuntodotnet/spunto-lite) both ship them.
6
+
7
+ Built on the same idea as [`@spunto/design-system`](https://www.npmjs.com/package/@spunto/design-system),
8
+ one layer down: that package owns what the two products *look* like, this one owns what they *agree
9
+ on*.
10
+
11
+ ```bash
12
+ npm install @spunto/build
13
+ ```
14
+
15
+ ## What's in it
16
+
17
+ ### `@spunto/build/steps` — the build-log protocol
18
+
19
+ A `docker build` for a devcontainer image does a dozen things in one opaque stream. Instead of a new
20
+ channel, the build script prints a marker around each block and the control plane parses it back out
21
+ of the log it already forwards:
22
+
23
+ ```ts
24
+ import { BuildStepTracker, stepStartLine, stepEndLine, type BuildStep } from "@spunto/build/steps"
25
+
26
+ // …in the script generator, next to the block it brackets:
27
+ const lines = [stepStartLine("feature:node"), installNode(), stepEndLine("feature:node")]
28
+
29
+ // …in whatever forwards the build log:
30
+ const tracker = new BuildStepTracker(plan)
31
+ const { text, changed } = tracker.ingest(chunk) // markers become banners, steps become live
32
+ ```
33
+
34
+ Also here: `stepMarker` (same bytes, for emitters that aren't a shell), `renderStoredBuildLog`
35
+ (replay a finished build with the same banners), and `EXTENSION_FAILED_MARKER` /
36
+ `parseFailedExtensions` — the second marker, for extensions code-server couldn't install.
37
+
38
+ ### `@spunto/build/extensions` — which registry, and how to ask it
39
+
40
+ code-server resolves `--install-extension <id>` against **one** registry. If your picker searches a
41
+ different one, users pick extensions the build cannot install. One entry point in front of both
42
+ supported protocols keeps that from happening:
43
+
44
+ ```ts
45
+ import { parseGallery, searchExtensions, codeServerGallery } from "@spunto/build/extensions"
46
+
47
+ const gallery = parseGallery(rawBlob) // null = Open VSX, the default
48
+ const hits = await searchExtensions(gallery, "prettier")
49
+ const forCodeServer = codeServerGallery(rawBlob) // the same choice, handed to the worker
50
+ ```
51
+
52
+ `parseGallery` takes the operator's raw value from wherever you keep it — a database column, an
53
+ environment variable — and every other function takes its result. Nothing is memoized at import
54
+ time, so one process can serve several tenants on several registries.
55
+
56
+ ## The rule
57
+
58
+ A module belongs in this package only if it imports **no** ORM schema, **no** HTTP framework, **no**
59
+ React, **no** `process.env`, and knows **nothing** about organizations, users or compute nodes.
60
+
61
+ Concretely: **no platform I/O**. No database, no Docker socket, no WebSocket. This package produces
62
+ strings and parses strings; its only network call is outbound HTTP to a public extension registry,
63
+ with no platform credential attached. Configuration arrives as function parameters — never read from
64
+ the environment — so that one control plane can scope a setting per organization and another per
65
+ process without either shape leaking in here.
66
+
67
+ That rule is what makes the package testable, safe to import from a node agent as well as from an
68
+ API, and the reason it can't simply be folded into the design system: a script generator needs
69
+ strict, canonical shapes, while the design system deliberately models everything as optional so a UI
70
+ degrades instead of throwing.
71
+
72
+ ## What's deliberately *not* in it
73
+
74
+ Image script generation (`buildImageScript` & co.), the devcontainer catalogs, the Docker client and
75
+ the terminal bridge — all still duplicated between the two products, all staged for later. The
76
+ reasoning, the measurements and the plan are in
77
+ [RFC 0021](https://github.com/coderhammer/spunto/blob/main/rfc/0021-paquet-partage-build.md).
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ npm install
83
+ npm test # vitest
84
+ npm run typecheck
85
+ ```
86
+
87
+ Published to npm from `main` by `.github/workflows/publish-build.yml`; bump the version in
88
+ `package.json` to cut a release.
89
+
90
+ ## License
91
+
92
+ MIT
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@spunto/build",
3
+ "version": "0.1.0",
4
+ "description": "Spunto's shared Build engine \u2014 the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://spunto.net",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/coderhammer/spunto.git",
11
+ "directory": "packages/build"
12
+ },
13
+ "keywords": [
14
+ "devcontainer",
15
+ "code-server",
16
+ "open-vsx",
17
+ "docker",
18
+ "spunto"
19
+ ],
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org/",
22
+ "access": "public"
23
+ },
24
+ "sideEffects": false,
25
+ "exports": {
26
+ ".": {
27
+ "types": "./src/index.ts",
28
+ "import": "./src/index.ts"
29
+ },
30
+ "./steps": {
31
+ "types": "./src/steps/index.ts",
32
+ "import": "./src/steps/index.ts"
33
+ },
34
+ "./extensions": {
35
+ "types": "./src/extensions/index.ts",
36
+ "import": "./src/extensions/index.ts"
37
+ }
38
+ },
39
+ "files": [
40
+ "src",
41
+ "!src/**/*.test.ts",
42
+ "README.md"
43
+ ],
44
+ "scripts": {
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest"
48
+ },
49
+ "devDependencies": {
50
+ "typescript": "^5",
51
+ "vitest": "^4.1.10"
52
+ }
53
+ }
@@ -0,0 +1,164 @@
1
+ // Single entry point for "which registry are extension ids resolved against?".
2
+ //
3
+ // Two protocols are supported and exactly one is active at a time:
4
+ // - Open VSX (./open-vsx) — the default, and what code-server uses out of the box;
5
+ // - an `extensionquery` gallery (./gallery-client), selected by an operator-supplied JSON blob.
6
+ //
7
+ // The point of routing everything through here is that the picker's search and the worker's
8
+ // code-server can't disagree: the same blob is what gets handed to code-server (at image-build time
9
+ // and at spawn time) *and* what selects the client below. An extension the UI can find stays an
10
+ // extension the worker can install.
11
+ //
12
+ // **Everything is a parameter.** Nothing is memoized at import time and nothing is read from the
13
+ // environment: every function takes the already-parsed gallery. That is what lets one control plane
14
+ // scope the setting per organization (a database column) and another scope it per process (an
15
+ // environment variable) without either shape leaking in here — each parses its own source once and
16
+ // passes the result down.
17
+ //
18
+ // No gallery is hardcoded and none is special-cased: whatever the operator supplies is the whole
19
+ // configuration.
20
+
21
+ import * as galleryClient from "./gallery-client"
22
+ import * as openVsx from "./open-vsx"
23
+ import { RegistryError, type ExtensionSuggestion } from "./extensions"
24
+
25
+ /** The parts of the operator's gallery blob this app reads itself. */
26
+ export type ExtensionGallery = {
27
+ /** Base of the gallery API — `<serviceUrl>/extensionquery` is what gets queried. */
28
+ serviceUrl: string
29
+ /** Human-facing item page, used to build "view on <registry>" links. */
30
+ itemUrl?: string
31
+ /** Product scope for galleries hosting several catalogs (see gallery-client). */
32
+ productTarget?: string
33
+ /** The blob to hand to code-server, normalized. */
34
+ forward: string
35
+ }
36
+
37
+ /** What a UI needs to name the active registry instead of hardcoding "Open VSX". */
38
+ export type ExtensionRegistryInfo = {
39
+ /** Display name — "Open VSX", or the configured gallery's host. */
40
+ name: string
41
+ /** Registry homepage, for "view on <name>" links. */
42
+ homeUrl?: string
43
+ /** True when the org configured a gallery, i.e. Open VSX is not in play. */
44
+ custom: boolean
45
+ }
46
+
47
+ function str(v: unknown): string | undefined {
48
+ return typeof v === "string" && v.trim() ? v.trim() : undefined
49
+ }
50
+
51
+ /**
52
+ * Parses an operator's gallery blob. Anything unusable — not JSON, not an object, no `serviceUrl`
53
+ * — comes back as `null`, i.e. "Open VSX".
54
+ *
55
+ * The fallback is the whole point: a blob we couldn't parse is a blob we'd be searching Open VSX
56
+ * against, and shipping it to code-server anyway would recreate the exact split-brain this module
57
+ * exists to prevent. `parseGalleryOrThrow` is the strict variant to validate a write with, so an
58
+ * unusable value can't reach storage in the first place.
59
+ */
60
+ export function parseGallery(raw: string | null | undefined): ExtensionGallery | null {
61
+ try {
62
+ return parseGalleryOrThrow(raw)
63
+ } catch (e) {
64
+ console.warn(`[extensions] Ignoring the configured extension gallery (${(e as Error).message}) — falling back to Open VSX`)
65
+ return null
66
+ }
67
+ }
68
+
69
+ /** Same parse, but throws on an unusable blob — use it to validate what you accept on write. */
70
+ export function parseGalleryOrThrow(raw: string | null | undefined): ExtensionGallery | null {
71
+ if (!raw || !raw.trim()) return null
72
+ let parsed: unknown
73
+ try {
74
+ parsed = JSON.parse(raw)
75
+ } catch {
76
+ throw new Error("not valid JSON")
77
+ }
78
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not a JSON object")
79
+ const obj = parsed as Record<string, unknown>
80
+ const serviceUrl = str(obj.serviceUrl)?.replace(/\/+$/, "")
81
+ if (!serviceUrl) throw new Error('missing "serviceUrl"')
82
+ try {
83
+ new URL(serviceUrl)
84
+ } catch {
85
+ throw new Error('"serviceUrl" is not an absolute URL')
86
+ }
87
+ return {
88
+ serviceUrl,
89
+ itemUrl: str(obj.itemUrl)?.replace(/\/+$/, ""),
90
+ productTarget: str(obj.productTarget),
91
+ // Everything the operator wrote is forwarded, not just the keys above: code-server understands
92
+ // more of them than we do (cacheUrl, controlUrl, …) and ignores the ones it doesn't.
93
+ forward: JSON.stringify({ ...obj, serviceUrl }),
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The `EXTENSIONS_GALLERY` value to give code-server, or null to leave it alone (Open VSX). Takes
99
+ * the raw stored value so callers don't have to parse first.
100
+ */
101
+ export function codeServerGallery(raw: string | null | undefined): string | null {
102
+ return parseGallery(raw)?.forward ?? null
103
+ }
104
+
105
+ /** Names the registry a given gallery (or `null` = the default) stands for. */
106
+ export function registryInfo(gallery: ExtensionGallery | null): ExtensionRegistryInfo {
107
+ if (!gallery) return { name: "Open VSX", homeUrl: "https://open-vsx.org", custom: false }
108
+ let name = "the configured gallery"
109
+ let homeUrl: string | undefined
110
+ try {
111
+ const url = new URL(gallery.serviceUrl)
112
+ name = url.hostname
113
+ homeUrl = gallery.itemUrl || url.origin
114
+ } catch {
115
+ /* a serviceUrl we can't parse still works as a fetch target; it just stays unnamed */
116
+ }
117
+ return { name, homeUrl, custom: true }
118
+ }
119
+
120
+ /** Registry page for one id, for the "view on <registry>" links. */
121
+ export function extensionUrl(gallery: ExtensionGallery | null, id: string): string | undefined {
122
+ if (!gallery) return `https://open-vsx.org/extension/${id.replace(".", "/")}`
123
+ return gallery.itemUrl ? `${gallery.itemUrl}?itemName=${encodeURIComponent(id)}` : undefined
124
+ }
125
+
126
+ /** Re-points registry-agnostic suggestions (ids only) at the active registry. */
127
+ export function withRegistryUrls(gallery: ExtensionGallery | null, suggestions: ExtensionSuggestion[]): ExtensionSuggestion[] {
128
+ return suggestions.map((s) => ({ ...s, url: extensionUrl(gallery, s.id) }))
129
+ }
130
+
131
+ /**
132
+ * Options that only apply to the default (Open VSX) branch — a gallery carries its own endpoint in
133
+ * the blob, so there is nothing left to configure once one is active.
134
+ */
135
+ export type RegistryOptions = {
136
+ /** Base of the Open VSX REST API, for a mirror. Ignored when a gallery is active. */
137
+ openVsxApiUrl?: string
138
+ }
139
+
140
+ /** Full-text search against the active registry. Throws `RegistryError` if it's down. */
141
+ export function searchExtensions(
142
+ gallery: ExtensionGallery | null,
143
+ query: string,
144
+ size?: number,
145
+ opts?: RegistryOptions,
146
+ ): Promise<ExtensionSuggestion[]> {
147
+ return gallery
148
+ ? galleryClient.searchExtensions(gallery, query, size)
149
+ : openVsx.searchExtensions(query, size, { apiUrl: opts?.openVsxApiUrl })
150
+ }
151
+
152
+ /** Exact lookup against the active registry. Null = a real "no such extension". */
153
+ export function lookupExtension(
154
+ gallery: ExtensionGallery | null,
155
+ id: string,
156
+ opts?: RegistryOptions,
157
+ ): Promise<ExtensionSuggestion | null> {
158
+ return gallery
159
+ ? galleryClient.lookupExtension(gallery, id)
160
+ : openVsx.lookupExtension(id, { apiUrl: opts?.openVsxApiUrl })
161
+ }
162
+
163
+ export { RegistryError }
164
+ export type { ExtensionSuggestion }
@@ -0,0 +1,46 @@
1
+ // Pure helpers and types around VS Code extension identifiers. No I/O — safe to import from a
2
+ // validation schema, from a client bundle, or from a script generator (see ./extension-registry for
3
+ // the clients that actually talk to a registry).
4
+
5
+ /**
6
+ * Registry call that couldn't be completed (network, timeout, 5xx). Shared by both registry clients
7
+ * so callers can tell "we couldn't ask" apart from a real "no such extension" verdict, whichever
8
+ * registry is active. It is an *upstream* failure: surface it as a 503, never a 500.
9
+ */
10
+ export class RegistryError extends Error {}
11
+
12
+ /** An extension as a picker shows it — a curated default, or a search hit from either registry. */
13
+ export type ExtensionSuggestion = {
14
+ /** `publisher.extension-id`, as passed to `code-server --install-extension`. */
15
+ id: string
16
+ /** Human-readable name, e.g. "Prettier - Code formatter". */
17
+ label: string
18
+ publisher: string
19
+ description?: string
20
+ downloads?: number
21
+ /** Publisher verified by the registry (Open VSX namespace ownership / gallery domain check). */
22
+ verified?: boolean
23
+ /** Latest published version, e.g. "11.0.0". */
24
+ version?: string
25
+ /** The extension's own icon, when the registry exposes one. */
26
+ iconUrl?: string
27
+ /** Registry page, for a "view on <registry>" link. */
28
+ url?: string
29
+ }
30
+
31
+ /**
32
+ * `publisher.extension-name`, the identifier code-server resolves against the registry. Deliberately
33
+ * permissive on the characters publishers actually use, strict on the shape (exactly one dot).
34
+ */
35
+ export function parseExtensionId(id: string): { publisher: string; name: string } | null {
36
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*?)\.([A-Za-z0-9][A-Za-z0-9._-]*)$/.exec(id.trim())
37
+ if (!m) return null
38
+ const [, publisher, name] = m
39
+ if (publisher.includes(".")) return null
40
+ return { publisher, name }
41
+ }
42
+
43
+ /** Shape check only — says nothing about the id existing on any registry. */
44
+ export function isExtensionId(id: string): boolean {
45
+ return parseExtensionId(id) !== null
46
+ }
@@ -0,0 +1,137 @@
1
+ // Client for a VS Code *extension gallery* — the `extensionquery` protocol code-server switches to
2
+ // when you hand it an `EXTENSIONS_GALLERY` JSON blob.
3
+ //
4
+ // It is nothing like Open VSX's REST API (./open-vsx): a gallery exposes a single
5
+ // `POST <serviceUrl>/extensionquery` endpoint taking a filter/flag document. That's why an Open VSX
6
+ // mirror URL could never double as a gallery setting — the two protocols don't share a single route.
7
+ //
8
+ // Nothing here is specific to any one gallery: the endpoint, the item URLs and the product scope all
9
+ // come from the operator's setting. Only the subset needed to fill an ExtensionSuggestion is
10
+ // implemented — search by text, and exact lookup of `publisher.name`.
11
+
12
+ import type { ExtensionGallery } from "./extension-registry"
13
+ import { RegistryError, parseExtensionId, type ExtensionSuggestion } from "./extensions"
14
+
15
+ const TIMEOUT_MS = 8000
16
+
17
+ /** `criteria[].filterType` values understood by the query endpoint. */
18
+ const FILTER = { ExtensionName: 7, Target: 8, SearchText: 10, ExcludeWithFlags: 12 } as const
19
+ /** `sortBy` / `sortOrder`: most installed first. */
20
+ const SORT_BY_INSTALL_COUNT = 4
21
+ const SORT_ORDER_DESCENDING = 2
22
+ /** IncludeVersions (0x1) | IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200). */
23
+ const FLAGS = 0x1 | 0x100 | 0x200
24
+ /** ExcludeWithFlags value that drops unpublished entries from the results. */
25
+ const UNPUBLISHED_FLAG = "4096"
26
+
27
+ type GalleryExtension = {
28
+ extensionName?: string
29
+ displayName?: string
30
+ shortDescription?: string
31
+ publisher?: { publisherName?: string; displayName?: string; isDomainVerified?: boolean }
32
+ statistics?: { statisticName?: string; value?: number }[]
33
+ /** Latest first, since the query asks for the latest version only. */
34
+ versions?: { version?: string }[]
35
+ }
36
+
37
+ type QueryResponse = { results?: { extensions?: GalleryExtension[] }[] }
38
+
39
+ type Criterion = { filterType: number; value: string }
40
+
41
+ function toSuggestion(gallery: ExtensionGallery, e: GalleryExtension): ExtensionSuggestion | null {
42
+ const publisher = e.publisher?.publisherName
43
+ if (!publisher || !e.extensionName) return null
44
+ const id = `${publisher}.${e.extensionName}`
45
+ return {
46
+ id,
47
+ label: e.displayName || e.extensionName,
48
+ publisher,
49
+ description: e.shortDescription || undefined,
50
+ downloads: e.statistics?.find((s) => s.statisticName === "install")?.value,
51
+ verified: e.publisher?.isDomainVerified === true,
52
+ version: e.versions?.[0]?.version,
53
+ // No `iconUrl`: a gallery only exposes icons behind an asset type whose identifier is
54
+ // vendor-specific, and this client stays gallery-agnostic. The field is optional and the cards
55
+ // fall back to a placeholder.
56
+ url: gallery.itemUrl ? `${gallery.itemUrl}?itemName=${encodeURIComponent(id)}` : undefined,
57
+ }
58
+ }
59
+
60
+ async function extensionQuery(
61
+ gallery: ExtensionGallery,
62
+ criteria: Criterion[],
63
+ pageSize: number,
64
+ ): Promise<GalleryExtension[]> {
65
+ const body = {
66
+ filters: [
67
+ {
68
+ criteria: [
69
+ // Galleries that host more than one product's catalog need to be told which one we want,
70
+ // or a search for "pipeline" happily returns extensions for IDEs code-server could never
71
+ // load. Operator-supplied and optional: a single-product gallery doesn't need it.
72
+ ...(gallery.productTarget ? [{ filterType: FILTER.Target, value: gallery.productTarget }] : []),
73
+ { filterType: FILTER.ExcludeWithFlags, value: UNPUBLISHED_FLAG },
74
+ ...criteria,
75
+ ],
76
+ pageNumber: 1,
77
+ pageSize,
78
+ sortBy: SORT_BY_INSTALL_COUNT,
79
+ sortOrder: SORT_ORDER_DESCENDING,
80
+ },
81
+ ],
82
+ assetTypes: [],
83
+ flags: FLAGS,
84
+ }
85
+
86
+ let res: Response
87
+ try {
88
+ res = await fetch(`${gallery.serviceUrl}/extensionquery`, {
89
+ method: "POST",
90
+ headers: {
91
+ // The api-version is not optional: without it the gallery answers 400.
92
+ accept: "application/json;api-version=3.0-preview.1",
93
+ "content-type": "application/json",
94
+ },
95
+ body: JSON.stringify(body),
96
+ signal: AbortSignal.timeout(TIMEOUT_MS),
97
+ })
98
+ } catch (e) {
99
+ throw new RegistryError(`Extension gallery unreachable: ${(e as Error).message}`)
100
+ }
101
+ if (!res.ok) throw new RegistryError(`Extension gallery query failed (${res.status})`)
102
+
103
+ const payload = (await res.json().catch(() => null)) as QueryResponse | null
104
+ if (!payload) throw new RegistryError("Extension gallery returned a malformed response")
105
+ return payload.results?.[0]?.extensions ?? []
106
+ }
107
+
108
+ /** Full-text search, most installed first. Throws `RegistryError` if the gallery is down. */
109
+ export async function searchExtensions(
110
+ gallery: ExtensionGallery,
111
+ query: string,
112
+ size = 20,
113
+ ): Promise<ExtensionSuggestion[]> {
114
+ const extensions = await extensionQuery(
115
+ gallery,
116
+ [{ filterType: FILTER.SearchText, value: query }],
117
+ Math.min(Math.max(size, 1), 50),
118
+ )
119
+ return extensions.map((e) => toSuggestion(gallery, e)).filter((e): e is ExtensionSuggestion => e !== null)
120
+ }
121
+
122
+ /**
123
+ * Exact lookup of `publisher.extension-id`.
124
+ * Returns null when the gallery has no such extension — a real verdict, unlike a `RegistryError`,
125
+ * which means we simply couldn't ask.
126
+ */
127
+ export async function lookupExtension(gallery: ExtensionGallery, id: string): Promise<ExtensionSuggestion | null> {
128
+ if (!parseExtensionId(id)) return null
129
+ // ExtensionName matches the *full* `publisher.name`, but the endpoint is happy to answer with
130
+ // near-misses, so the id still has to be checked on the way out.
131
+ const extensions = await extensionQuery(gallery, [{ filterType: FILTER.ExtensionName, value: id }], 1)
132
+ for (const e of extensions) {
133
+ const suggestion = toSuggestion(gallery, e)
134
+ if (suggestion && suggestion.id.toLowerCase() === id.toLowerCase()) return suggestion
135
+ }
136
+ return null
137
+ }
@@ -0,0 +1,30 @@
1
+ // Resolving VS Code extension ids: which registry answers, and how to ask it.
2
+ //
3
+ // One entry point (`./extension-registry`) sits in front of two protocols — Open VSX and an
4
+ // `extensionquery` gallery — so that the picker's search and the worker's code-server can never end
5
+ // up on different registries. Start at `parseGallery`; everything else takes its result.
6
+ //
7
+ // Nothing here touches a database or an environment variable. The only I/O is outbound HTTP to a
8
+ // public registry, with no platform credential attached — see the package README.
9
+
10
+ export { isExtensionId, parseExtensionId, RegistryError } from "./extensions"
11
+ export type { ExtensionSuggestion } from "./extensions"
12
+
13
+ export {
14
+ codeServerGallery,
15
+ extensionUrl,
16
+ lookupExtension,
17
+ parseGallery,
18
+ parseGalleryOrThrow,
19
+ registryInfo,
20
+ searchExtensions,
21
+ withRegistryUrls,
22
+ } from "./extension-registry"
23
+ export type { ExtensionGallery, ExtensionRegistryInfo, RegistryOptions } from "./extension-registry"
24
+
25
+ // The two clients, for a caller that already knows which protocol it wants. `./extension-registry`
26
+ // is the normal way in — reach for these only to bypass the choice deliberately.
27
+ export { DEFAULT_OPEN_VSX_API } from "./open-vsx"
28
+ export type { OpenVsxOptions } from "./open-vsx"
29
+ export * as openVsx from "./open-vsx"
30
+ export * as gallery from "./gallery-client"
@@ -0,0 +1,109 @@
1
+ // Open VSX registry client — the registry code-server resolves `--install-extension <id>` against
2
+ // out of the box.
3
+ //
4
+ // Keeping search and install on one registry is the point: an extension the picker can find is an
5
+ // extension the build can install. Which registry that is isn't decided here —
6
+ // ./extension-registry picks between this client and the gallery client depending on the caller's
7
+ // gallery setting, and hands the same choice to code-server so the two can't drift apart.
8
+ //
9
+ // The base URL is a **parameter**, not an environment variable. Both control planes expose the same
10
+ // `OPEN_VSX_API` knob for pointing at a mirror, but each reads its own environment at its own
11
+ // boundary and passes the result down: a package that reached into `process.env` at import time
12
+ // would be untestable, unusable outside Node, and would make the mirror a property of the process
13
+ // rather than of the call.
14
+
15
+ import { RegistryError, parseExtensionId, type ExtensionSuggestion } from "./extensions"
16
+
17
+ /** Where an unconfigured caller resolves ids. A mirror is passed per call, see `OpenVsxOptions`. */
18
+ export const DEFAULT_OPEN_VSX_API = "https://open-vsx.org/api"
19
+
20
+ export type OpenVsxOptions = {
21
+ /** Base of the Open VSX REST API, for a mirror. Defaults to `DEFAULT_OPEN_VSX_API`. */
22
+ apiUrl?: string
23
+ }
24
+
25
+ function apiBase(opts?: OpenVsxOptions): string {
26
+ return (opts?.apiUrl?.trim() || DEFAULT_OPEN_VSX_API).replace(/\/+$/, "")
27
+ }
28
+
29
+ const TIMEOUT_MS = 8000
30
+
31
+ type OpenVsxExtension = {
32
+ name?: string
33
+ namespace?: string
34
+ displayName?: string
35
+ description?: string
36
+ downloadCount?: number
37
+ verified?: boolean
38
+ deprecated?: boolean
39
+ version?: string
40
+ /** `/-/search` flattens the icon here; `/{namespace}/{name}` nests it under `files`. */
41
+ files?: { icon?: string | null } | null
42
+ }
43
+
44
+ function toSuggestion(e: OpenVsxExtension): ExtensionSuggestion | null {
45
+ if (!e.namespace || !e.name) return null
46
+ return {
47
+ id: `${e.namespace}.${e.name}`,
48
+ label: e.displayName || e.name,
49
+ publisher: e.namespace,
50
+ description: e.description || undefined,
51
+ downloads: e.downloadCount,
52
+ verified: e.verified,
53
+ version: e.version,
54
+ // Passed through rather than dropped: the picker's cards draw the real icon, and it's already
55
+ // in the payload the registry answered with.
56
+ iconUrl: e.files?.icon ?? undefined,
57
+ url: `https://open-vsx.org/extension/${e.namespace}/${e.name}`,
58
+ }
59
+ }
60
+
61
+ async function registryFetch(path: string, opts?: OpenVsxOptions): Promise<Response> {
62
+ try {
63
+ return await fetch(`${apiBase(opts)}${path}`, {
64
+ headers: { accept: "application/json" },
65
+ signal: AbortSignal.timeout(TIMEOUT_MS),
66
+ })
67
+ } catch (e) {
68
+ throw new RegistryError(`Open VSX unreachable: ${(e as Error).message}`)
69
+ }
70
+ }
71
+
72
+ /** Full-text search, most downloaded first. Throws `RegistryError` if the registry is down. */
73
+ export async function searchExtensions(
74
+ query: string,
75
+ size = 20,
76
+ opts?: OpenVsxOptions,
77
+ ): Promise<ExtensionSuggestion[]> {
78
+ const params = new URLSearchParams({
79
+ query,
80
+ size: String(Math.min(Math.max(size, 1), 50)),
81
+ sortBy: "downloadCount",
82
+ sortOrder: "desc",
83
+ includeAllVersions: "false",
84
+ })
85
+ const res = await registryFetch(`/-/search?${params}`, opts)
86
+ if (!res.ok) throw new RegistryError(`Open VSX search failed (${res.status})`)
87
+ const body = (await res.json()) as { extensions?: OpenVsxExtension[] }
88
+ return (body.extensions ?? [])
89
+ .filter((e) => !e.deprecated)
90
+ .map(toSuggestion)
91
+ .filter((e): e is ExtensionSuggestion => e !== null)
92
+ }
93
+
94
+ /**
95
+ * Exact lookup of `publisher.extension-id`.
96
+ * Returns null when the registry answers "no such extension" — a real verdict, unlike a
97
+ * `RegistryError`, which means we simply couldn't ask.
98
+ */
99
+ export async function lookupExtension(id: string, opts?: OpenVsxOptions): Promise<ExtensionSuggestion | null> {
100
+ const parts = parseExtensionId(id)
101
+ if (!parts) return null
102
+ const res = await registryFetch(`/${encodeURIComponent(parts.publisher)}/${encodeURIComponent(parts.name)}`, opts)
103
+ if (res.status === 404) return null
104
+ if (!res.ok) throw new RegistryError(`Open VSX lookup failed (${res.status})`)
105
+ const body = (await res.json()) as OpenVsxExtension & { error?: string }
106
+ // Open VSX answers 200 with an `error` payload for unknown namespaces.
107
+ if (body.error || !body.namespace || !body.name) return null
108
+ return toSuggestion(body)
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ // @spunto/build — the parts of Spunto's Build pillar that are the same whoever runs the control
2
+ // plane: the build-log protocol, and VS Code extension resolution.
3
+ //
4
+ // Subpath imports are the normal way in (`@spunto/build/steps`, `@spunto/build/extensions`); this
5
+ // root re-exports both for callers that would rather have one import. Nothing in this package reads
6
+ // a database, opens a socket, or renders anything — see README.md for the rule and why it matters.
7
+
8
+ export * from "./steps/index"
9
+ export * from "./extensions/index"
@@ -0,0 +1,198 @@
1
+ // Image build, seen as a list of blocks instead of a wall of log.
2
+ //
3
+ // A project image build does a lot in one `docker build`: pull the base image, create the user,
4
+ // install code-server, install tmux, install an SSH server, install each devcontainer feature,
5
+ // install each VS Code extension, then bake the DinD seed. The only thing a UI could show without
6
+ // this is the raw log, which tells you *something is happening* but not *what*.
7
+ //
8
+ // The mechanism is deliberately the cheapest one that works end-to-end: the generated build script
9
+ // `echo`es a marker line around each block, and the control plane parses those markers out of the
10
+ // log stream it already forwards. No extra channel, no protocol change on the wire — the "hook" is
11
+ // a line of output. Anything that can print to stdout inside (or beside) the build can therefore
12
+ // declare a step, including code that runs next to `docker build` rather than in it (the DinD seed
13
+ // bake is declared exactly this way).
14
+ //
15
+ // **Why this lives in a package.** A marker is a contract between three programs that cannot import
16
+ // each other: the shell script that emits it, the control plane that parses it, and the UI that
17
+ // renders the result. Spunto Cloud had the emitter in its API and the same literal re-typed by hand
18
+ // in its node agent; Spunto Lite had neither. One copy, here, is what keeps them from drifting.
19
+ //
20
+ // The step *plan* is meant to be built at the same place the script is generated, so the list a UI
21
+ // shows greyed-out upfront can't drift from the blocks the script actually runs. Persist it next to
22
+ // the build and a finished build replays with its blocks and timings, exactly like the live one.
23
+
24
+ export type BuildStepState = "pending" | "running" | "done" | "error" | "skipped"
25
+
26
+ /** Drives the icon in the UI — a family of block, not a status. */
27
+ export type BuildStepKind = "image" | "runtime" | "feature" | "extensions" | "finalize"
28
+
29
+ export type BuildStep = {
30
+ /** Stable identifier, also what the markers carry (e.g. `code-server`, `feature:node`). */
31
+ id: string
32
+ label: string
33
+ kind: BuildStepKind
34
+ /** Secondary line: the image ref, the feature's OCI ref, "3 extensions"… */
35
+ detail?: string
36
+ state: BuildStepState
37
+ /** ISO timestamps, stamped by the control plane when the markers are seen. */
38
+ startedAt?: string
39
+ completedAt?: string
40
+ }
41
+
42
+ const MARKER_PREFIX = "::spunto:step:"
43
+ const MARKER_RE = /^::spunto:step:(start|end):(.+?)[ \t]*$/
44
+
45
+ /** Shell line a build script emits to declare that a block starts. */
46
+ export function stepStartLine(id: string): string {
47
+ return `echo '${MARKER_PREFIX}start:${id}'`
48
+ }
49
+
50
+ /** Shell line a build script emits to declare that a block finished successfully. */
51
+ export function stepEndLine(id: string): string {
52
+ return `echo '${MARKER_PREFIX}end:${id}'`
53
+ }
54
+
55
+ /** The same two markers, as plain strings — for emitters that aren't shell (the agent). */
56
+ export const stepMarker = {
57
+ start: (id: string) => `${MARKER_PREFIX}start:${id}\n`,
58
+ end: (id: string) => `${MARKER_PREFIX}end:${id}\n`,
59
+ }
60
+
61
+ function durationLabel(step: BuildStep): string {
62
+ if (!step.startedAt || !step.completedAt) return ""
63
+ const ms = new Date(step.completedAt).getTime() - new Date(step.startedAt).getTime()
64
+ if (!(ms > 0)) return ""
65
+ return ms < 1000 ? ` — ${ms}ms` : ` — ${(ms / 1000).toFixed(1)}s`
66
+ }
67
+
68
+ /**
69
+ * What replaces a marker in the terminal. The markers are machine-readable, but the log is still
70
+ * the thing a human scrolls through when a build fails: turning them into a banner makes the raw
71
+ * output readable block by block too, instead of leaving a stray `::spunto:step:…` line behind.
72
+ */
73
+ // Newlines are bare `\n`: the caller is expected to run the text through the same xterm
74
+ // normalisation it already applies to the rest of the log stream, which is what turns them into
75
+ // `\r\n`. Emitting `\r\n` here would double up for callers that normalise.
76
+ function startBanner(step: BuildStep): string {
77
+ return `\n\x1b[38;5;208m▸\x1b[0m \x1b[1m${step.label}\x1b[0m\n`
78
+ }
79
+
80
+ function endBanner(step: BuildStep): string {
81
+ return `\x1b[2m ✓ ${step.label}${durationLabel(step)}\x1b[0m\n`
82
+ }
83
+
84
+ /** True when `s` could still grow into a marker — used to decide whether to hold a partial line. */
85
+ function couldBeMarker(s: string): boolean {
86
+ return s.length > 0 && (MARKER_PREFIX.startsWith(s) || s.startsWith(MARKER_PREFIX))
87
+ }
88
+
89
+ /**
90
+ * Consumes a build's log stream, keeps the step list up to date, and hands back the text to show.
91
+ *
92
+ * Chunk boundaries are respected: a trailing partial line is only held back when it could still
93
+ * become a marker, so pull-progress redraws (which carry cursor-control sequences and no marker)
94
+ * keep flowing through untouched.
95
+ */
96
+ export class BuildStepTracker {
97
+ private residual = ""
98
+
99
+ constructor(public steps: BuildStep[]) {}
100
+
101
+ private find(id: string): BuildStep | undefined {
102
+ return this.steps.find((s) => s.id === id)
103
+ }
104
+
105
+ /** Feed a raw chunk. Returns the text to forward and whether the step list changed. */
106
+ ingest(chunk: string, now = new Date()): { text: string; changed: boolean } {
107
+ const buf = this.residual + chunk
108
+ const parts = buf.split("\n")
109
+ this.residual = parts.pop() ?? ""
110
+
111
+ let text = ""
112
+ let changed = false
113
+
114
+ for (const rawLine of parts) {
115
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine
116
+ const m = MARKER_RE.exec(line)
117
+ if (!m) {
118
+ text += rawLine + "\n"
119
+ continue
120
+ }
121
+ const [, kind, id] = m
122
+ const step = this.find(id)
123
+ if (!step) continue // unknown id — swallow the marker rather than leak it to the terminal
124
+ if (kind === "start") {
125
+ // Starting a block implicitly closes whatever was still running: the agent-side phases
126
+ // (base image pull) have no end marker of their own, they end when the script speaks.
127
+ for (const s of this.steps) {
128
+ if (s !== step && s.state === "running") {
129
+ s.state = "done"
130
+ s.completedAt = now.toISOString()
131
+ text += endBanner(s)
132
+ }
133
+ }
134
+ step.state = "running"
135
+ step.startedAt = now.toISOString()
136
+ text += startBanner(step)
137
+ } else {
138
+ step.state = "done"
139
+ step.completedAt = now.toISOString()
140
+ text += endBanner(step)
141
+ }
142
+ changed = true
143
+ }
144
+
145
+ if (this.residual && !couldBeMarker(this.residual)) {
146
+ text += this.residual
147
+ this.residual = ""
148
+ }
149
+
150
+ return { text, changed }
151
+ }
152
+
153
+ /**
154
+ * Close the list when the build itself ends. Success marks everything left as done; a failure
155
+ * marks the running block as the culprit and everything after it as never-reached.
156
+ */
157
+ finish(state: "ready" | "error", now = new Date()): { text: string; changed: boolean } {
158
+ let text = ""
159
+ if (this.residual) {
160
+ text += this.residual
161
+ this.residual = ""
162
+ }
163
+ let changed = false
164
+ for (const s of this.steps) {
165
+ if (s.state === "running") {
166
+ s.state = state === "ready" ? "done" : "error"
167
+ s.completedAt = now.toISOString()
168
+ if (state === "ready") text += endBanner(s)
169
+ changed = true
170
+ } else if (s.state === "pending") {
171
+ s.state = state === "ready" ? "done" : "skipped"
172
+ changed = true
173
+ }
174
+ }
175
+ return { text, changed }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Re-render a stored log for replay: same banners as the live stream, using the timings already
181
+ * recorded on the persisted steps. Keeps "watch a build" and "reopen a finished build" identical.
182
+ */
183
+ export function renderStoredBuildLog(logs: string, steps: BuildStep[] | null | undefined): string {
184
+ if (!steps || steps.length === 0) return logs.replace(new RegExp(`^${MARKER_PREFIX}.*$\n?`, "gm"), "")
185
+ const byId = new Map(steps.map((s) => [s.id, s]))
186
+ return logs
187
+ .split("\n")
188
+ .map((rawLine) => {
189
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine
190
+ const m = MARKER_RE.exec(line)
191
+ if (!m) return rawLine
192
+ const step = byId.get(m[2])
193
+ if (!step) return null
194
+ return m[1] === "start" ? startBanner(step).replace(/\n$/, "") : endBanner(step).replace(/\n$/, "")
195
+ })
196
+ .filter((l): l is string => l !== null)
197
+ .join("\n")
198
+ }
@@ -0,0 +1,35 @@
1
+ // The second marker a build script emits, and the one Spunto Cloud is currently missing.
2
+ //
3
+ // Installing a VS Code extension is a nice-to-have, not a prerequisite: a miss must not fail the
4
+ // build. But "must not fail the build" degenerated into "is never reported" — and code-server does
5
+ // not reliably exit non-zero on an unknown id, so testing `$?` alone lets an
6
+ // `Extension 'x' not found` scroll past as a success. A project ends up running without the
7
+ // extension it was configured with, and nothing anywhere says so.
8
+ //
9
+ // Same shape as `./build-steps`: the script prints a prefixed line, the control plane greps it back
10
+ // out of the log it already stores. `emitExtensionFailure` is the shell-side half,
11
+ // `parseFailedExtensions` the reader. Keeping the two in one file is the point — a marker whose
12
+ // emitter and parser live apart is a marker that breaks silently.
13
+ //
14
+ // Note what this does *not* prescribe: whether to also check that the extension landed on disk,
15
+ // what to write into the image, when to give up. That is the build script's business (see
16
+ // `buildImageScript` in each control plane). This file owns the wire format and nothing else.
17
+
18
+ /** Prefix every line reporting an extension the build could not install carries. */
19
+ export const EXTENSION_FAILED_MARKER = "[build] EXTENSION FAILED:"
20
+
21
+ const FAILED_RE = /^\[build\] EXTENSION FAILED: (\S+)/gm
22
+
23
+ /**
24
+ * Shell line a build script emits for one extension it failed to install. `id` is interpolated as
25
+ * written, so pass a literal id or a shell expansion (`"$_ext"`) — never unvalidated user input on
26
+ * a path that isn't already quoted by the caller.
27
+ */
28
+ export function extensionFailedLine(id: string, reason?: string): string {
29
+ return reason ? `${EXTENSION_FAILED_MARKER} ${id} — ${reason}` : `${EXTENSION_FAILED_MARKER} ${id}`
30
+ }
31
+
32
+ /** Extension ids the given build log reports as failed, in script order, deduplicated. */
33
+ export function parseFailedExtensions(logs: string): string[] {
34
+ return [...new Set(Array.from(logs.matchAll(FAILED_RE), (m) => m[1]))]
35
+ }
@@ -0,0 +1,16 @@
1
+ // The build-log protocol: what a build script prints so that a control plane can turn a wall of
2
+ // text into a list of blocks, and a list of extensions it failed to install.
3
+ //
4
+ // Nothing here reads a database, opens a socket, or renders anything. Emitters produce strings,
5
+ // parsers consume strings — see the package README for the rule this entry point obeys.
6
+
7
+ export {
8
+ BuildStepTracker,
9
+ renderStoredBuildLog,
10
+ stepEndLine,
11
+ stepMarker,
12
+ stepStartLine,
13
+ } from "./build-steps"
14
+ export type { BuildStep, BuildStepKind, BuildStepState } from "./build-steps"
15
+
16
+ export { EXTENSION_FAILED_MARKER, extensionFailedLine, parseFailedExtensions } from "./extension-failures"