@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 +92 -0
- package/package.json +53 -0
- package/src/extensions/extension-registry.ts +164 -0
- package/src/extensions/extensions.ts +46 -0
- package/src/extensions/gallery-client.ts +137 -0
- package/src/extensions/index.ts +30 -0
- package/src/extensions/open-vsx.ts +109 -0
- package/src/index.ts +9 -0
- package/src/steps/build-steps.ts +198 -0
- package/src/steps/extension-failures.ts +35 -0
- package/src/steps/index.ts +16 -0
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"
|