@spunto/build 0.2.0 → 0.3.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 +32 -0
- package/package.json +10 -2
- package/src/index.ts +2 -1
- package/src/naming/index.ts +20 -4
- package/src/spec/index.ts +162 -0
package/README.md
CHANGED
|
@@ -75,11 +75,43 @@ paths a worker and its control plane agree on — `WORKER_STATUS_FILE` / `worker
|
|
|
75
75
|
running machines: a live worker's container and volumes keep the name they were created with. Read
|
|
76
76
|
a diff in that file as a migration, not a rename.
|
|
77
77
|
|
|
78
|
+
### `@spunto/build/spec` — a project as a file you can carry between Spuntos
|
|
79
|
+
|
|
80
|
+
Both products describe the same thing — an image, some features, some extensions, repositories,
|
|
81
|
+
lifecycle commands. Lite could already export that to JSON; Cloud could not. And Lite's format
|
|
82
|
+
announced itself as `spunto-lite/project`, putting the producer's name in the signature and making
|
|
83
|
+
the file local by construction.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { buildProjectSpec, parseProjectSpec, projectSpecFilename } from "@spunto/build/spec"
|
|
87
|
+
|
|
88
|
+
const file = JSON.stringify(buildProjectSpec(project), null, 2) // kind: "spunto/project"
|
|
89
|
+
const spec = parseProjectSpec(await uploaded.text()) // throws a toast-safe message
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Files exported by Lite before this package are still accepted on read; new ones always carry the
|
|
93
|
+
neutral kind.
|
|
94
|
+
|
|
95
|
+
**Secrets travel as names only** — values are write-only by design, and a spec is a file people
|
|
96
|
+
mail each other. Instance identity (id, version history, deploy key) never travels either.
|
|
97
|
+
|
|
98
|
+
**Shape here, policy there.** These schemas check that a file is well-formed, not that it is
|
|
99
|
+
acceptable: mount-path rules, extension-id existence, git-ref validity all stay with the product
|
|
100
|
+
doing the import, which validates its own create payload anyway. And a product **ignores the
|
|
101
|
+
fields it has no feature for** — shared volumes are a Lite concept, the task settings a Cloud one,
|
|
102
|
+
both optional. Dropping a field the target cannot honour is the correct outcome; refusing the file
|
|
103
|
+
is not.
|
|
104
|
+
|
|
78
105
|
## The rule
|
|
79
106
|
|
|
80
107
|
A module belongs in this package only if it imports **no** ORM schema, **no** HTTP framework, **no**
|
|
81
108
|
React, **no** `process.env`, and knows **nothing** about organizations, users or compute nodes.
|
|
82
109
|
|
|
110
|
+
`zod` is the one exception, and a deliberate one: `./spec` describes a file format that two
|
|
111
|
+
products have to validate identically, and re-deriving the same schema on each side is exactly the
|
|
112
|
+
duplication this package exists to remove. It is a **peer** dependency (`^4`), so a consumer's own
|
|
113
|
+
zod is the one used — which matters, since apps extend these schemas with their own.
|
|
114
|
+
|
|
83
115
|
Concretely: **no platform I/O**. No database, no Docker socket, no WebSocket. This package produces
|
|
84
116
|
strings and parses strings; its only network call is outbound HTTP to a public extension registry,
|
|
85
117
|
with no platform credential attached. Configuration arrives as function parameters — never read from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
"./naming": {
|
|
39
39
|
"types": "./src/naming/index.ts",
|
|
40
40
|
"import": "./src/naming/index.ts"
|
|
41
|
+
},
|
|
42
|
+
"./spec": {
|
|
43
|
+
"types": "./src/spec/index.ts",
|
|
44
|
+
"import": "./src/spec/index.ts"
|
|
41
45
|
}
|
|
42
46
|
},
|
|
43
47
|
"files": [
|
|
@@ -52,6 +56,10 @@
|
|
|
52
56
|
},
|
|
53
57
|
"devDependencies": {
|
|
54
58
|
"typescript": "^5",
|
|
55
|
-
"vitest": "^4.1.10"
|
|
59
|
+
"vitest": "^4.1.10",
|
|
60
|
+
"zod": "^4.6.5"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"zod": "^4"
|
|
56
64
|
}
|
|
57
65
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// plane: the build-log protocol, and VS Code extension resolution.
|
|
3
3
|
//
|
|
4
4
|
// Subpath imports are the normal way in (`@spunto/build/steps`, `@spunto/build/extensions`); this
|
|
5
|
-
// root re-exports
|
|
5
|
+
// root re-exports them all for callers that would rather have one import. Nothing in this package reads
|
|
6
6
|
// a database, opens a socket, or renders anything — see README.md for the rule and why it matters.
|
|
7
7
|
|
|
8
8
|
export * from "./steps/index"
|
|
9
9
|
export * from "./extensions/index"
|
|
10
10
|
export * from "./naming/index"
|
|
11
|
+
export * from "./spec/index"
|
package/src/naming/index.ts
CHANGED
|
@@ -29,7 +29,13 @@ export function workerNetworkName(workerId: string): string {
|
|
|
29
29
|
return `mp-worker-${workerId}-net`
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The named volumes a worker may own.
|
|
34
|
+
*
|
|
35
|
+
* `home` is historical: nothing creates it any more, but workers created before it was dropped
|
|
36
|
+
* still have one, so teardown has to keep looking for it. Spawning creates `workspace`, plus
|
|
37
|
+
* `docker` and `containerd` when the project runs docker-in-docker.
|
|
38
|
+
*/
|
|
33
39
|
export const WORKER_VOLUME_KINDS = ["workspace", "home", "docker", "containerd"] as const
|
|
34
40
|
export type WorkerVolumeKind = (typeof WORKER_VOLUME_KINDS)[number]
|
|
35
41
|
|
|
@@ -92,10 +98,20 @@ export function workerStatusPath(home: string): string {
|
|
|
92
98
|
export const EXTENSIONS_DIR = "/opt/mp-extensions"
|
|
93
99
|
|
|
94
100
|
/**
|
|
95
|
-
*
|
|
96
|
-
* The devcontainer `entrypoint` contract, implemented on our side
|
|
101
|
+
* **A file, not a directory** — a newline-separated, `sort -u`'d list of commands to run at
|
|
102
|
+
* container start. The devcontainer `entrypoint` contract, implemented on our side: a feature
|
|
103
|
+
* declares one, the image build appends it here, every boot reads the file back and runs each line.
|
|
104
|
+
*
|
|
105
|
+
* (Named `_DIR` in 0.2.0, which was wrong and is corrected here before anything depended on it.)
|
|
106
|
+
*/
|
|
107
|
+
export const FEATURE_ENTRYPOINTS_FILE = "/etc/mp-feature-entrypoints"
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Where entrypoints are accumulated *during* the image build, before being merged into
|
|
111
|
+
* `FEATURE_ENTRYPOINTS_FILE`. Separate because each feature install runs in its own layer and
|
|
112
|
+
* appends blindly; the merge is what deduplicates.
|
|
97
113
|
*/
|
|
98
|
-
export const
|
|
114
|
+
export const FEATURE_ENTRYPOINTS_STAGING_FILE = "/tmp/mp-feature-entrypoints"
|
|
99
115
|
|
|
100
116
|
/**
|
|
101
117
|
* Extensions the image build could not install, one id per line. Left in the image so a worker can
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// A project, as a file you can carry from one Spunto to another.
|
|
2
|
+
//
|
|
3
|
+
// Both products describe the same thing — a devcontainer environment: an image, some features,
|
|
4
|
+
// some extensions, repositories to clone, lifecycle commands. Spunto Lite could already export
|
|
5
|
+
// that to JSON and import it back; Spunto Cloud could not. And Lite's format announced itself as
|
|
6
|
+
// `spunto-lite/project`, which put the producer's name in the format's signature and made the
|
|
7
|
+
// file local by construction.
|
|
8
|
+
//
|
|
9
|
+
// One format, `spunto/project`, is what turns "prototype it locally, then move it to the cloud"
|
|
10
|
+
// from a slide into a feature.
|
|
11
|
+
//
|
|
12
|
+
// **What travels and what does not.** The spec holds what describes the *environment*, never what
|
|
13
|
+
// identifies an instance: no id, no version history, no deploy key, no favourite flag. Secrets
|
|
14
|
+
// travel as **names only** — their values are write-only by design, and a spec is a file people
|
|
15
|
+
// mail each other. The names are there so an import can lay out the rows left to fill in.
|
|
16
|
+
//
|
|
17
|
+
// **Shape here, policy there.** These schemas check that a file is well-formed, not that it is
|
|
18
|
+
// acceptable: whether a mount path is allowed, whether an extension id exists on the configured
|
|
19
|
+
// registry, whether a branch name is valid git — all of that stays with the product doing the
|
|
20
|
+
// import, which validates its own create payload anyway. A format that enforced one product's
|
|
21
|
+
// policy would stop being portable the day the other's policy differed.
|
|
22
|
+
//
|
|
23
|
+
// **A product ignores the fields it has no feature for.** Shared volumes are a Lite concept, the
|
|
24
|
+
// task settings are a Cloud one; both are optional, and importing a Cloud spec into Lite is
|
|
25
|
+
// supposed to drop the task settings rather than fail. Losing a field the target cannot honour is
|
|
26
|
+
// the correct outcome — refusing the file is not.
|
|
27
|
+
|
|
28
|
+
import { z } from "zod"
|
|
29
|
+
|
|
30
|
+
/** What a file produced today announces itself as. */
|
|
31
|
+
export const PROJECT_SPEC_KIND = "spunto/project"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Kinds still accepted on read. `spunto-lite/project` is what every file exported by Spunto Lite
|
|
35
|
+
* before this package says, and those files are on people's disks — refusing them to tidy up a
|
|
36
|
+
* string would break the only half of the feature that already shipped.
|
|
37
|
+
*/
|
|
38
|
+
export const LEGACY_PROJECT_SPEC_KINDS = ["spunto-lite/project"] as const
|
|
39
|
+
|
|
40
|
+
export const PROJECT_SPEC_VERSION = 1
|
|
41
|
+
|
|
42
|
+
const KindSchema = z
|
|
43
|
+
.string()
|
|
44
|
+
.refine(
|
|
45
|
+
(k) => k === PROJECT_SPEC_KIND || (LEGACY_PROJECT_SPEC_KINDS as readonly string[]).includes(k),
|
|
46
|
+
`Expected "${PROJECT_SPEC_KIND}"`,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
/** A repository cloned into the workspace. `id` is an instance handle — optional in a file. */
|
|
50
|
+
export const SpecRepositorySchema = z.object({
|
|
51
|
+
id: z.string().optional(),
|
|
52
|
+
/** `git` = cloned from a raw URL with the project's deploy key, rather than through a forge app. */
|
|
53
|
+
provider: z.enum(["github", "gitlab", "bitbucket", "git"]),
|
|
54
|
+
/** `owner/repo`, or a human label for a raw URL. */
|
|
55
|
+
project: z.string().min(1),
|
|
56
|
+
/** Directory under `/workspace`. */
|
|
57
|
+
workspacePath: z.string().min(1),
|
|
58
|
+
cloneUrl: z.string().optional(),
|
|
59
|
+
/** Absent/empty = whatever the remote's default is. We do not guess it. */
|
|
60
|
+
branch: z.string().optional(),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const SpecFeatureSchema = z.object({
|
|
64
|
+
id: z.string().min(1),
|
|
65
|
+
/** Set for a hand-typed OCI ref that is not in the catalog of the instance that exported. */
|
|
66
|
+
ociRef: z.string().optional(),
|
|
67
|
+
options: z.record(z.string(), z.string()).optional(),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/** Lite's project-level volume, mounted in every worker of the project. */
|
|
71
|
+
export const SpecSharedVolumeSchema = z.object({
|
|
72
|
+
name: z.string().min(1),
|
|
73
|
+
mountPath: z.string().min(1),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export const SpecProjectSchema = z.object({
|
|
77
|
+
name: z.string().min(1),
|
|
78
|
+
description: z.string().nullable().optional(),
|
|
79
|
+
image: z.string().min(1),
|
|
80
|
+
features: z.array(SpecFeatureSchema).default([]),
|
|
81
|
+
vscodeExtensions: z.array(z.string()).default([]),
|
|
82
|
+
prewarmImages: z.array(z.string()).default([]),
|
|
83
|
+
dind: z.boolean().default(false),
|
|
84
|
+
postCreateCommand: z.string().nullable().optional(),
|
|
85
|
+
postStartCommand: z.string().nullable().optional(),
|
|
86
|
+
repositories: z.array(SpecRepositorySchema).default([]),
|
|
87
|
+
forwardPorts: z.array(z.number().int().min(1).max(65535)).default([]),
|
|
88
|
+
/** Names, never values. See the note at the top of this file. */
|
|
89
|
+
secretNames: z.array(z.string()).default([]),
|
|
90
|
+
|
|
91
|
+
// ── Optional, product-specific. Absent is normal; unsupported is not an error. ──
|
|
92
|
+
|
|
93
|
+
/** Spunto Lite. A declaration only — the volume is created on first spawn wherever it lands. */
|
|
94
|
+
sharedVolumes: z.array(SpecSharedVolumeSchema).optional(),
|
|
95
|
+
|
|
96
|
+
/** Spunto Cloud — how delegated work runs and reports. Meaningless to a product without tasks. */
|
|
97
|
+
taskAgentCommand: z.string().optional(),
|
|
98
|
+
taskAgentProtocol: z.enum(["none", "claude-json", "claude-stream", "jsonl"]).optional(),
|
|
99
|
+
taskFollowUpCommand: z.string().optional(),
|
|
100
|
+
taskResetCommand: z.string().optional(),
|
|
101
|
+
taskValidateCommand: z.string().optional(),
|
|
102
|
+
taskCancelCommand: z.string().optional(),
|
|
103
|
+
taskReviewMode: z.enum(["stop", "keep"]).optional(),
|
|
104
|
+
taskAgentModel: z.string().optional(),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
export const ProjectSpecSchema = z.object({
|
|
108
|
+
kind: KindSchema,
|
|
109
|
+
version: z.literal(PROJECT_SPEC_VERSION),
|
|
110
|
+
/** Informational only — nothing reads it back. */
|
|
111
|
+
exportedAt: z.string().optional(),
|
|
112
|
+
project: SpecProjectSchema,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
export type ProjectSpec = z.infer<typeof ProjectSpecSchema>
|
|
116
|
+
export type SpecProject = z.infer<typeof SpecProjectSchema>
|
|
117
|
+
export type SpecRepository = z.infer<typeof SpecRepositorySchema>
|
|
118
|
+
|
|
119
|
+
/** Wraps a project in a fresh envelope. Always writes the current kind, never a legacy one. */
|
|
120
|
+
export function buildProjectSpec(project: SpecProject, now: Date = new Date()): ProjectSpec {
|
|
121
|
+
return {
|
|
122
|
+
kind: PROJECT_SPEC_KIND,
|
|
123
|
+
version: PROJECT_SPEC_VERSION,
|
|
124
|
+
exportedAt: now.toISOString(),
|
|
125
|
+
project,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Download filename for a project's spec, e.g. `my-app.spunto-project.json`. */
|
|
130
|
+
export function projectSpecFilename(name: string): string {
|
|
131
|
+
const slug = name
|
|
132
|
+
.toLowerCase()
|
|
133
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
134
|
+
.replace(/^-|-$/g, "")
|
|
135
|
+
return `${slug || "project"}.spunto-project.json`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Parses the text of an uploaded file into a validated spec.
|
|
140
|
+
*
|
|
141
|
+
* Throws an `Error` whose message is safe to put straight in a toast — this runs on a file a human
|
|
142
|
+
* picked, so "why can't I import this?" has to be answerable without opening a console.
|
|
143
|
+
*/
|
|
144
|
+
export function parseProjectSpec(text: string): ProjectSpec {
|
|
145
|
+
let raw: unknown
|
|
146
|
+
try {
|
|
147
|
+
raw = JSON.parse(text)
|
|
148
|
+
} catch {
|
|
149
|
+
throw new Error("Not a valid JSON file")
|
|
150
|
+
}
|
|
151
|
+
const parsed = ProjectSpecSchema.safeParse(raw)
|
|
152
|
+
if (parsed.success) return parsed.data
|
|
153
|
+
|
|
154
|
+
// A file that at least claims to be a spec deserves to be told what is wrong with it; anything
|
|
155
|
+
// else is someone importing the wrong file, and the useful answer is which file to look for.
|
|
156
|
+
const looksLikeSpec = typeof raw === "object" && raw !== null && "kind" in raw
|
|
157
|
+
throw new Error(
|
|
158
|
+
looksLikeSpec
|
|
159
|
+
? `Unsupported project spec: ${z.prettifyError(parsed.error).split("\n")[0]}`
|
|
160
|
+
: "Not a Spunto project spec",
|
|
161
|
+
)
|
|
162
|
+
}
|