@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 +54 -0
- package/package.json +14 -2
- package/src/index.ts +3 -1
- package/src/naming/index.ts +121 -0
- package/src/spec/index.ts +162 -0
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.
|
|
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
|
|
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
|
+
}
|