@spunto/build 0.13.0 → 0.15.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 +16 -0
- package/package.json +1 -1
- package/src/script/setup-script.ts +13 -3
- package/src/spec/index.ts +87 -0
package/README.md
CHANGED
|
@@ -104,6 +104,22 @@ fields it has no feature for** — shared volumes are a Lite concept, the task s
|
|
|
104
104
|
both optional. Dropping a field the target cannot honour is the correct outcome; refusing the file
|
|
105
105
|
is not.
|
|
106
106
|
|
|
107
|
+
**The project's prompt is a file of its own.** `taskAgentInstructions` is prose, and a JSON string
|
|
108
|
+
is the worst place to read it. A spec may instead point at a sibling file
|
|
109
|
+
(`taskAgentInstructionsFile: "prompt.md"`); a **bundle** is the spec plus what it points at, as a
|
|
110
|
+
plain name → text map — zipping it, or picking both files at once, is the product's job.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { buildProjectBundle, parseProjectBundle, projectBundleFilename } from "@spunto/build/spec"
|
|
114
|
+
|
|
115
|
+
const files = buildProjectBundle(project) // { "my-app.spunto-project.json": …, "prompt.md": … }
|
|
116
|
+
const spec = parseProjectBundle(files) // prompt back inline, as taskAgentInstructions
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A project without a prompt is a one-file bundle whose spec is what `buildProjectSpec` writes, and a
|
|
120
|
+
lone spec is a valid bundle — so every file exported before bundles existed imports as before. The
|
|
121
|
+
inline `taskAgentInstructions` stays accepted too (it is what a single-JSON export writes).
|
|
122
|
+
|
|
107
123
|
### `@spunto/build/script` — turning a project spec into the shell a worker runs
|
|
108
124
|
|
|
109
125
|
Four generators, one recipe: `buildImageScript` bakes the project image, `buildSetupScript` is the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Spunto's shared Build engine — 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",
|
|
@@ -103,6 +103,16 @@ type CloneRepo = {
|
|
|
103
103
|
branch?: string
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* How a feature's entrypoint is started: without the OpenTelemetry variables the platform gives the
|
|
108
|
+
* workload. An entrypoint is infrastructure — dockerd, containerd — and Docker instruments itself as
|
|
109
|
+
* soon as `OTEL_EXPORTER_OTLP_ENDPOINT` exists: every internal gRPC call of a docker-in-docker
|
|
110
|
+
* daemon became a span (~60 000 per quarter hour for three sandboxes, 2026-10-18), drowning the
|
|
111
|
+
* project's own services in its organization's telemetry. The project's processes keep them.
|
|
112
|
+
*/
|
|
113
|
+
export const ENTRYPOINT_WITHOUT_OTEL =
|
|
114
|
+
"env -u OTEL_EXPORTER_OTLP_ENDPOINT -u OTEL_EXPORTER_OTLP_TRACES_ENDPOINT -u OTEL_EXPORTER_OTLP_METRICS_ENDPOINT -u OTEL_EXPORTER_OTLP_LOGS_ENDPOINT -u OTEL_EXPORTER_OTLP_PROTOCOL -u OTEL_EXPORTER_OTLP_HEADERS -u OTEL_RESOURCE_ATTRIBUTES -u OTEL_SERVICE_NAME"
|
|
115
|
+
|
|
106
116
|
/**
|
|
107
117
|
* Branch actually checked out for a repo: the worker-level choice (made at creation, applies to
|
|
108
118
|
* every repo of the project) wins over the project-level default carried by the repository itself.
|
|
@@ -1140,7 +1150,7 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
|
|
|
1140
1150
|
" while IFS= read -r _ep; do",
|
|
1141
1151
|
' if [ -f "$_ep" ]; then',
|
|
1142
1152
|
' echo "Starting entrypoint: $_ep"',
|
|
1143
|
-
' "$_ep" &',
|
|
1153
|
+
' ' + ENTRYPOINT_WITHOUT_OTEL + ' "$_ep" &',
|
|
1144
1154
|
" fi",
|
|
1145
1155
|
" done < " + FEATURE_ENTRYPOINTS_FILE,
|
|
1146
1156
|
"fi",
|
|
@@ -1306,7 +1316,7 @@ export function buildStartScript(params: StartScriptParams): { script: string; h
|
|
|
1306
1316
|
" while IFS= read -r _ep; do",
|
|
1307
1317
|
' if [ -f "$_ep" ]; then',
|
|
1308
1318
|
' echo "Starting entrypoint: $_ep"',
|
|
1309
|
-
' "$_ep" &',
|
|
1319
|
+
' ' + ENTRYPOINT_WITHOUT_OTEL + ' "$_ep" &',
|
|
1310
1320
|
" fi",
|
|
1311
1321
|
" done < " + FEATURE_ENTRYPOINTS_FILE,
|
|
1312
1322
|
"fi",
|
|
@@ -1559,7 +1569,7 @@ export function buildContainerScript(params: StartScriptParams): { script: strin
|
|
|
1559
1569
|
" while IFS= read -r _ep; do",
|
|
1560
1570
|
' if [ -f "$_ep" ]; then',
|
|
1561
1571
|
' echo "Starting entrypoint: $_ep"',
|
|
1562
|
-
' "$_ep" &',
|
|
1572
|
+
' ' + ENTRYPOINT_WITHOUT_OTEL + ' "$_ep" &',
|
|
1563
1573
|
" fi",
|
|
1564
1574
|
" done < " + FEATURE_ENTRYPOINTS_FILE,
|
|
1565
1575
|
"fi",
|
package/src/spec/index.ts
CHANGED
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
// task settings are a Cloud one; both are optional, and importing a Cloud spec into Lite is
|
|
25
25
|
// supposed to drop the task settings rather than fail. Losing a field the target cannot honour is
|
|
26
26
|
// the correct outcome — refusing the file is not.
|
|
27
|
+
//
|
|
28
|
+
// **The prompt is a file of its own.** A project's agent prompt (`taskAgentInstructions`) is
|
|
29
|
+
// prose — paragraphs, lists, code fences — and a JSON string is the worst place to read or edit
|
|
30
|
+
// it. So a spec may *point* at it (`taskAgentInstructionsFile: "prompt.md"`) instead of holding it,
|
|
31
|
+
// and a **bundle** is the spec plus the files it points at (`buildProjectBundle` /
|
|
32
|
+
// `parseProjectBundle`). The bundle is a plain name → text map: how it is carried (a zip, two
|
|
33
|
+
// files picked together, a folder in a repo) is the product's business, not this package's. The
|
|
34
|
+
// inline form stays valid — it is what a single-file export writes, and every file written
|
|
35
|
+
// before the pointer existed is a file with no prompt at all.
|
|
27
36
|
|
|
28
37
|
import { z } from "zod"
|
|
29
38
|
|
|
@@ -102,6 +111,13 @@ export const SpecProjectSchema = z.object({
|
|
|
102
111
|
taskCancelCommand: z.string().optional(),
|
|
103
112
|
taskReviewMode: z.enum(["stop", "keep"]).optional(),
|
|
104
113
|
taskAgentModel: z.string().optional(),
|
|
114
|
+
/** Spunto Cloud — what the project's task agents are told on top of the org's instruction. */
|
|
115
|
+
taskAgentInstructions: z.string().optional(),
|
|
116
|
+
/**
|
|
117
|
+
* The same prompt, as a sibling file of the bundle (`prompt.md`) rather than a string here.
|
|
118
|
+
* Resolved by `parseProjectBundle`; `parseProjectSpec` alone leaves it as a name.
|
|
119
|
+
*/
|
|
120
|
+
taskAgentInstructionsFile: z.string().min(1).optional(),
|
|
105
121
|
})
|
|
106
122
|
|
|
107
123
|
export const ProjectSpecSchema = z.object({
|
|
@@ -160,3 +176,74 @@ export function parseProjectSpec(text: string): ProjectSpec {
|
|
|
160
176
|
: "Not a Spunto project spec",
|
|
161
177
|
)
|
|
162
178
|
}
|
|
179
|
+
|
|
180
|
+
/** Name of the prompt file in a bundle written by `buildProjectBundle`. */
|
|
181
|
+
export const PROJECT_PROMPT_FILENAME = "prompt.md"
|
|
182
|
+
|
|
183
|
+
/** A spec and the files it points at, by name. Text only — a bundle carries no binaries. */
|
|
184
|
+
export type ProjectBundle = Record<string, string>
|
|
185
|
+
|
|
186
|
+
/** Download filename for a project's bundle once zipped, e.g. `my-app.spunto-project.zip`. */
|
|
187
|
+
export function projectBundleFilename(name: string): string {
|
|
188
|
+
return projectSpecFilename(name).replace(/\.json$/, ".zip")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The project as a bundle: its spec, plus the prompt as `prompt.md` when it has one.
|
|
193
|
+
*
|
|
194
|
+
* A project without a prompt is a one-file bundle whose spec is byte-for-byte what
|
|
195
|
+
* `buildProjectSpec` would have written — nothing changes for a reader that only knows the JSON.
|
|
196
|
+
*/
|
|
197
|
+
export function buildProjectBundle(project: SpecProject, now: Date = new Date()): ProjectBundle {
|
|
198
|
+
const { taskAgentInstructions, taskAgentInstructionsFile: _ignored, ...rest } = project
|
|
199
|
+
const specName = projectSpecFilename(project.name)
|
|
200
|
+
if (!taskAgentInstructions) return { [specName]: JSON.stringify(buildProjectSpec(rest, now), null, 2) + "\n" }
|
|
201
|
+
const spec = buildProjectSpec({ ...rest, taskAgentInstructionsFile: PROJECT_PROMPT_FILENAME }, now)
|
|
202
|
+
return {
|
|
203
|
+
[specName]: JSON.stringify(spec, null, 2) + "\n",
|
|
204
|
+
// A trailing newline, like any text file an editor saves; `parseProjectBundle` trims it back.
|
|
205
|
+
[PROJECT_PROMPT_FILENAME]: taskAgentInstructions.endsWith("\n") ? taskAgentInstructions : taskAgentInstructions + "\n",
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `./prompt.md`, `dir/prompt.md` (a zip made from a folder) and `prompt.md` are the same file. */
|
|
210
|
+
function bundleKey(path: string): string {
|
|
211
|
+
return path.replace(/\\/g, "/").split("/").filter((s) => s && s !== ".").pop() ?? ""
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Reads a bundle back into a spec whose prompt, if any, is inline (`taskAgentInstructions`) and
|
|
216
|
+
* whose `taskAgentInstructionsFile` is gone — the shape a product creates a project from.
|
|
217
|
+
*
|
|
218
|
+
* The spec is the bundle's one `.json` file (a `*.spunto-project.json` wins if there are several);
|
|
219
|
+
* a lone spec is a valid bundle, which is what makes every file exported before bundles existed
|
|
220
|
+
* import exactly as before. Files are matched by base name, so a zip of a folder works too.
|
|
221
|
+
*
|
|
222
|
+
* Throws messages written for a toast, like `parseProjectSpec`.
|
|
223
|
+
*/
|
|
224
|
+
export function parseProjectBundle(bundle: ProjectBundle): ProjectSpec {
|
|
225
|
+
const files = new Map<string, string>()
|
|
226
|
+
for (const [path, text] of Object.entries(bundle)) {
|
|
227
|
+
const key = bundleKey(path)
|
|
228
|
+
// macOS archives carry `__MACOSX/._x` shadows of every file; they are never what was meant.
|
|
229
|
+
if (key && !key.startsWith("._") && !path.includes("__MACOSX")) files.set(key, text)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const jsons = [...files.keys()].filter((k) => k.toLowerCase().endsWith(".json"))
|
|
233
|
+
const preferred = jsons.filter((k) => k.toLowerCase().endsWith(".spunto-project.json"))
|
|
234
|
+
const candidates = preferred.length ? preferred : jsons
|
|
235
|
+
if (candidates.length === 0) throw new Error("No project spec (.json) in what was imported")
|
|
236
|
+
if (candidates.length > 1) throw new Error(`Several project specs to choose from: ${candidates.join(", ")}`)
|
|
237
|
+
|
|
238
|
+
const spec = parseProjectSpec(files.get(candidates[0])!)
|
|
239
|
+
const { taskAgentInstructionsFile: ref, ...project } = spec.project
|
|
240
|
+
if (!ref) return spec
|
|
241
|
+
|
|
242
|
+
const text = files.get(bundleKey(ref))
|
|
243
|
+
if (text === undefined) {
|
|
244
|
+
throw new Error(`The spec's prompt is in ${ref}, which was not imported with it — pick both files, or the .zip`)
|
|
245
|
+
}
|
|
246
|
+
// The file wins over an inline copy: it is the one a human edits.
|
|
247
|
+
const prompt = text.replace(/\s+$/, "")
|
|
248
|
+
return { ...spec, project: { ...project, ...(prompt ? { taskAgentInstructions: prompt } : {}) } }
|
|
249
|
+
}
|