@spunto/build 0.1.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 CHANGED
@@ -53,11 +53,65 @@ const forCodeServer = codeServerGallery(rawBlob) // the same choice, handed to t
53
53
  environment variable — and every other function takes its result. Nothing is memoized at import
54
54
  time, so one process can serve several tenants on several registries.
55
55
 
56
+ ### `@spunto/build/naming` — the names both sides have to pronounce the same way
57
+
58
+ A worker is a container called `mp-worker-<id>`, with volumes and a network named after it, running
59
+ an image called `mp-proj-<slug>:v<n>`. None of that is an implementation detail — each name is a
60
+ contract between the script that writes it, the Docker client that looks it up and the UI that
61
+ shows it:
62
+
63
+ ```ts
64
+ import { workerContainerName, workerVolumePrefix, projectImageRef } from "@spunto/build/naming"
65
+
66
+ docker.getContainer(workerContainerName(workerId))
67
+ const ref = projectImageRef(projectId, version) // mp-proj-my-app:v7
68
+ ```
69
+
70
+ Also here: `workerNetworkName`, `workerVolumeName`, `isProjectImageRef`, `SHARED_NETWORK`, and the
71
+ paths a worker and its control plane agree on — `WORKER_STATUS_FILE` / `workerStatusPath`,
72
+ `EXTENSIONS_DIR`, `FEATURE_ENTRYPOINTS_DIR`, `EXTENSION_FAILURES_FILE`.
73
+
74
+ **These are formats, not preferences.** Changing a value renames things that already exist on
75
+ running machines: a live worker's container and volumes keep the name they were created with. Read
76
+ a diff in that file as a migration, not a rename.
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
+
56
105
  ## The rule
57
106
 
58
107
  A module belongs in this package only if it imports **no** ORM schema, **no** HTTP framework, **no**
59
108
  React, **no** `process.env`, and knows **nothing** about organizations, users or compute nodes.
60
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
+
61
115
  Concretely: **no platform I/O**. No database, no Docker socket, no WebSocket. This package produces
62
116
  strings and parses strings; its only network call is outbound HTTP to a public extension registry,
63
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.1.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",
@@ -34,6 +34,14 @@
34
34
  "./extensions": {
35
35
  "types": "./src/extensions/index.ts",
36
36
  "import": "./src/extensions/index.ts"
37
+ },
38
+ "./naming": {
39
+ "types": "./src/naming/index.ts",
40
+ "import": "./src/naming/index.ts"
41
+ },
42
+ "./spec": {
43
+ "types": "./src/spec/index.ts",
44
+ "import": "./src/spec/index.ts"
37
45
  }
38
46
  },
39
47
  "files": [
@@ -48,6 +56,10 @@
48
56
  },
49
57
  "devDependencies": {
50
58
  "typescript": "^5",
51
- "vitest": "^4.1.10"
59
+ "vitest": "^4.1.10",
60
+ "zod": "^4.6.5"
61
+ },
62
+ "peerDependencies": {
63
+ "zod": "^4"
52
64
  }
53
65
  }
package/src/index.ts CHANGED
@@ -2,8 +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 both for callers that would rather have one import. Nothing in this package reads
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
+ export * from "./naming/index"
11
+ export * from "./spec/index"
@@ -0,0 +1,121 @@
1
+ // The names both control planes have to pronounce the same way.
2
+ //
3
+ // A worker is a container called `mp-worker-<id>`, with volumes named after it, on a network named
4
+ // after it, running an image called `mp-proj-<slug>:v<n>`, writing its progress to a file at a
5
+ // fixed path. None of that is an implementation detail: each name is a contract between the shell
6
+ // script that writes it, the Docker client that looks it up, and the UI that shows it.
7
+ //
8
+ // Until now none of them had a single definition. `mp-worker-<id>` was rebuilt by hand in fourteen
9
+ // places across two apps — and a typo in one of them doesn't fail to compile, it produces a
10
+ // container nobody can find at runtime. Spunto Lite rebuilds the same names again, on its side.
11
+ //
12
+ // **These are formats, not preferences.** Changing a value here renames things that already exist
13
+ // on real machines: a running worker's container and volumes keep the name they were created with.
14
+ // Treat every constant below as versioned protocol, the same way `../steps` treats its markers.
15
+ //
16
+ // The `mp-` prefix is historical (the product was called something else) and is deliberately left
17
+ // alone: it is baked into containers, volumes and images that are running right now.
18
+
19
+ /** Container that *is* the worker. */
20
+ export function workerContainerName(workerId: string): string {
21
+ return `mp-worker-${workerId}`
22
+ }
23
+
24
+ /**
25
+ * Per-worker bridge network. Isolates each worker at L3 so no worker can reach another worker's
26
+ * container IP directly, bypassing the proxy.
27
+ */
28
+ export function workerNetworkName(workerId: string): string {
29
+ return `mp-worker-${workerId}-net`
30
+ }
31
+
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
+ */
39
+ export const WORKER_VOLUME_KINDS = ["workspace", "home", "docker", "containerd"] as const
40
+ export type WorkerVolumeKind = (typeof WORKER_VOLUME_KINDS)[number]
41
+
42
+ /** One of a worker's named volumes — `workspace` for the code, `docker`/`containerd` for DinD. */
43
+ export function workerVolumeName(workerId: string, kind: WorkerVolumeKind): string {
44
+ return `mp-worker-${workerId}-${kind}`
45
+ }
46
+
47
+ /**
48
+ * Prefix every volume of one worker shares. Teardown and disk accounting both scan by prefix rather
49
+ * than by listing kinds, so that a volume added later is still found by code written before it.
50
+ */
51
+ export function workerVolumePrefix(workerId: string): string {
52
+ return `mp-worker-${workerId}-`
53
+ }
54
+
55
+ /**
56
+ * Image a project's workers boot from, keyed by the project's *version* — see the image cache in
57
+ * docs/architecture-deep-dive.md. The slug is derived, not stored: an image ref may only hold
58
+ * lowercase alphanumerics and separators, and a project id is not guaranteed to.
59
+ */
60
+ export function projectImageRef(projectId: string, version: number): string {
61
+ const slug = projectId
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]/g, "-")
64
+ .replace(/-+/g, "-")
65
+ .replace(/^-|-$/g, "")
66
+ return `mp-proj-${slug}:v${version}`
67
+ }
68
+
69
+ /** True for an image this platform built, as opposed to a base image it pulled. */
70
+ export function isProjectImageRef(ref: string): boolean {
71
+ return /^mp-proj-[a-z0-9-]+:v\d+$/.test(ref)
72
+ }
73
+
74
+ /**
75
+ * Network every shared service joins under its slug, and every worker joins at spawn, so a
76
+ * workspace reaches `http://<slug>:<port>` by DNS. Spunto Lite's "shared services"; the local
77
+ * counterpart of the Ship pillar.
78
+ */
79
+ export const SHARED_NETWORK = "mp-shared-net"
80
+
81
+ /**
82
+ * Where a worker reports its own setup progress, read back by the control plane.
83
+ *
84
+ * Relative to the user's home rather than absolute: the remote user is a project setting, so the
85
+ * absolute path is only known once you know who the worker runs as — hence `workerStatusPath`.
86
+ */
87
+ export const WORKER_STATUS_FILE = ".mp-status.json"
88
+
89
+ /** Absolute path of the status file for a given home directory, e.g. `/home/vscode`. */
90
+ export function workerStatusPath(home: string): string {
91
+ return `${home.replace(/\/+$/, "")}/${WORKER_STATUS_FILE}`
92
+ }
93
+
94
+ /**
95
+ * VS Code extensions are installed into the *image*, at a system path — not into a home, which is
96
+ * per worker and would make every worker re-download them.
97
+ */
98
+ export const EXTENSIONS_DIR = "/opt/mp-extensions"
99
+
100
+ /**
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.
113
+ */
114
+ export const FEATURE_ENTRYPOINTS_STAGING_FILE = "/tmp/mp-feature-entrypoints"
115
+
116
+ /**
117
+ * Extensions the image build could not install, one id per line. Left in the image so a worker can
118
+ * tell what is missing without anyone re-reading the build log — see `EXTENSION_FAILED_MARKER` in
119
+ * `../steps`, which is the same information on its way out.
120
+ */
121
+ export const EXTENSION_FAILURES_FILE = "/etc/mp-extension-failures"
@@ -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
+ }