summer-engine 4.3.0 → 4.5.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.
Files changed (218) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +3 -1
  3. package/.codex-plugin/plugin.json +5 -3
  4. package/.cursor-plugin/plugin.json +4 -2
  5. package/.factory-plugin/plugin.json +4 -2
  6. package/AGENTS.md +2 -2
  7. package/CHANGELOG.md +87 -0
  8. package/README.md +67 -27
  9. package/commands/start.md +19 -15
  10. package/commands/summer.md +1 -0
  11. package/dist/cli/command-usage.d.ts +3 -1
  12. package/dist/cli/command-usage.js +10 -4
  13. package/dist/cli/commands/create.d.ts +1 -25
  14. package/dist/cli/commands/create.js +10 -138
  15. package/dist/cli/commands/dev.d.ts +66 -0
  16. package/dist/cli/commands/dev.js +234 -0
  17. package/dist/cli/commands/install.d.ts +15 -58
  18. package/dist/cli/commands/install.js +31 -481
  19. package/dist/cli/commands/login.js +1 -1
  20. package/dist/cli/commands/ops.d.ts +17 -1
  21. package/dist/cli/commands/ops.js +97 -9
  22. package/dist/cli/commands/preview.d.ts +58 -0
  23. package/dist/cli/commands/preview.js +181 -0
  24. package/dist/cli/commands/run.d.ts +1 -17
  25. package/dist/cli/commands/run.js +17 -206
  26. package/dist/cli/index.js +5 -0
  27. package/dist/core/agent-host.d.ts +3 -0
  28. package/dist/core/agent-host.js +1 -1
  29. package/dist/core/api-client.d.ts +10 -2
  30. package/dist/core/api-client.js +13 -8
  31. package/dist/core/capabilities/agent-playbook.js +1 -0
  32. package/dist/core/capabilities/apps.d.ts +9 -9
  33. package/dist/core/capabilities/asset-import.d.ts +4 -1
  34. package/dist/core/capabilities/asset-import.js +15 -6
  35. package/dist/core/capabilities/capture-gameplay.js +1 -1
  36. package/dist/core/capabilities/connect-signal.d.ts +2 -2
  37. package/dist/core/capabilities/content-make/index.d.ts +81 -0
  38. package/dist/core/capabilities/content-make/index.js +706 -0
  39. package/dist/core/capabilities/content-make/media.d.ts +57 -0
  40. package/dist/core/capabilities/content-make/media.js +172 -0
  41. package/dist/core/capabilities/content-make/plan.d.ts +130 -0
  42. package/dist/core/capabilities/content-make/plan.js +433 -0
  43. package/dist/core/capabilities/content-make/render.d.ts +64 -0
  44. package/dist/core/capabilities/content-make/render.js +370 -0
  45. package/dist/core/capabilities/content-make/study.d.ts +13 -0
  46. package/dist/core/capabilities/content-make/study.js +165 -0
  47. package/dist/core/capabilities/content-make/types.d.ts +173 -0
  48. package/dist/core/capabilities/content-make/types.js +6 -0
  49. package/dist/core/capabilities/content-make/voice.d.ts +7 -0
  50. package/dist/core/capabilities/content-make/voice.js +27 -0
  51. package/dist/core/capabilities/events.d.ts +2 -2
  52. package/dist/core/capabilities/export-game.d.ts +4 -1
  53. package/dist/core/capabilities/export-game.js +4 -3
  54. package/dist/core/capabilities/export-presets.d.ts +2 -1
  55. package/dist/core/capabilities/export-presets.js +2 -1
  56. package/dist/core/capabilities/go-live.d.ts +14 -0
  57. package/dist/core/capabilities/go-live.js +408 -0
  58. package/dist/core/capabilities/image-generation.d.ts +6 -6
  59. package/dist/core/capabilities/item-manifest.d.ts +92 -0
  60. package/dist/core/capabilities/item-manifest.js +247 -0
  61. package/dist/core/capabilities/placement.d.ts +7 -7
  62. package/dist/core/capabilities/projects-local.d.ts +68 -0
  63. package/dist/core/capabilities/projects-local.js +172 -0
  64. package/dist/core/capabilities/publish-build.d.ts +13 -2
  65. package/dist/core/capabilities/publish-build.js +87 -21
  66. package/dist/core/capabilities/publish-download.js +1 -1
  67. package/dist/core/capabilities/runtime-control.d.ts +7 -2
  68. package/dist/core/capabilities/runtime-control.js +27 -1
  69. package/dist/core/capabilities/seeing/args.d.ts +2 -2
  70. package/dist/core/capabilities/store-checks.d.ts +45 -0
  71. package/dist/core/capabilities/store-checks.js +85 -0
  72. package/dist/core/capabilities/store-details.d.ts +37 -0
  73. package/dist/core/capabilities/store-details.js +94 -0
  74. package/dist/core/capabilities/summer-bundle.d.ts +21 -2
  75. package/dist/core/capabilities/summer-bundle.js +54 -7
  76. package/dist/core/capabilities/sync-items.d.ts +89 -0
  77. package/dist/core/capabilities/sync-items.js +352 -0
  78. package/dist/core/capabilities/tool-dispatch.js +66 -6
  79. package/dist/core/capabilities/video-ogv.d.ts +49 -0
  80. package/dist/core/capabilities/video-ogv.js +260 -0
  81. package/dist/core/capability-skew.js +2 -2
  82. package/dist/core/cli-usage-beacon.js +26 -2
  83. package/dist/core/cli-usage.d.ts +5 -2
  84. package/dist/core/cli-usage.js +8 -2
  85. package/dist/core/engine-install.d.ts +53 -0
  86. package/dist/core/engine-install.js +93 -0
  87. package/dist/core/engine-installer.d.ts +134 -0
  88. package/dist/core/engine-installer.js +760 -0
  89. package/dist/core/engine-launch.d.ts +126 -0
  90. package/dist/core/engine-launch.js +398 -0
  91. package/dist/core/engine-self-start.d.ts +63 -0
  92. package/dist/core/engine-self-start.js +282 -0
  93. package/dist/core/engine.d.ts +7 -1
  94. package/dist/core/engine.js +9 -2
  95. package/dist/core/error-codes.d.ts +4 -1
  96. package/dist/core/error-codes.js +7 -3
  97. package/dist/core/library-read.d.ts +2 -2
  98. package/dist/core/library-search.d.ts +2 -2
  99. package/dist/core/local-project.d.ts +50 -0
  100. package/dist/core/local-project.js +234 -0
  101. package/dist/core/ops/areas.js +9 -3
  102. package/dist/core/ops/catalog.d.ts +9 -0
  103. package/dist/core/ops/catalog.js +56 -4
  104. package/dist/core/ops/engine-answer.d.ts +4 -1
  105. package/dist/core/ops/engine-answer.js +16 -3
  106. package/dist/core/ops/engine.js +4 -4
  107. package/dist/core/ops/save-files.d.ts +6 -1
  108. package/dist/core/ops/save-files.js +67 -50
  109. package/dist/core/ops/search.js +9 -2
  110. package/dist/core/preview/input.d.ts +35 -0
  111. package/dist/core/preview/input.js +105 -0
  112. package/dist/core/preview/launch-config.d.ts +52 -0
  113. package/dist/core/preview/launch-config.js +137 -0
  114. package/dist/core/preview/page.d.ts +22 -0
  115. package/dist/core/preview/page.js +232 -0
  116. package/dist/core/preview/preview-source.d.ts +179 -0
  117. package/dist/core/preview/preview-source.js +350 -0
  118. package/dist/core/preview/server.d.ts +26 -0
  119. package/dist/core/preview/server.js +218 -0
  120. package/dist/core/telemetry.d.ts +20 -16
  121. package/dist/core/telemetry.js +92 -16
  122. package/dist/core/templates.d.ts +20 -0
  123. package/dist/core/templates.js +300 -3
  124. package/dist/core/tool-profiles.d.ts +2 -2
  125. package/dist/core/tool-profiles.js +1 -0
  126. package/dist/core/usage-fields.d.ts +41 -0
  127. package/dist/core/usage-fields.js +150 -0
  128. package/dist/core/util/sleep.d.ts +3 -0
  129. package/dist/core/util/sleep.js +12 -0
  130. package/dist/mcp/ops-surface.js +28 -2
  131. package/dist/mcp/server.d.ts +14 -4
  132. package/dist/mcp/server.js +42 -8
  133. package/dist/mcp/tool-surface.d.ts +8 -0
  134. package/dist/mcp/tool-surface.js +19 -2
  135. package/dist/mcp/tools/creator-tools.js +92 -14
  136. package/dist/mcp/tools/debug-tools.js +2 -0
  137. package/dist/mcp/tools/local-project-tools.d.ts +6 -0
  138. package/dist/mcp/tools/local-project-tools.js +33 -0
  139. package/dist/mcp/tools/navigation-tools.js +2 -1
  140. package/dist/mcp/tools/project-tools.js +5 -8
  141. package/dist/mcp/tools/seeing-tools.js +1 -1
  142. package/dist/mcp/tools/visual-tools.js +1 -1
  143. package/dist/mcp/tools/with-engine.js +1 -1
  144. package/gemini-extension.json +3 -1
  145. package/library/references/mcp-tools-reference/mcp-tools-reference.md +18 -5
  146. package/library/skills/README.md +12 -8
  147. package/library/skills/animated-loop/SKILL.md +10 -16
  148. package/library/skills/cinematic-cutscene/SKILL.md +7 -18
  149. package/library/skills/headless-scripting/SKILL.md +7 -4
  150. package/library/skills/in-game-purchases/SKILL.md +25 -8
  151. package/library/skills/in-game-purchases/resource.yaml +2 -1
  152. package/library/skills/launch-ready/SKILL.md +351 -0
  153. package/library/skills/launch-ready/resource.yaml +45 -0
  154. package/library/skills/make-content/SKILL.md +202 -0
  155. package/library/skills/make-content/resource.yaml +32 -0
  156. package/library/skills/make-game/SKILL.md +2 -0
  157. package/library/skills/make-game/resource.yaml +2 -1
  158. package/library/skills/navigate-summer/SKILL.md +4 -2
  159. package/library/skills/new-project/SKILL.md +15 -22
  160. package/library/skills/new-project/resource.yaml +2 -1
  161. package/library/skills/painted-25d-world/SKILL.md +366 -0
  162. package/library/skills/painted-25d-world/_building-blocks/blender-route.md +33 -0
  163. package/library/skills/painted-25d-world/_building-blocks/blockout_builder.gd +149 -0
  164. package/library/skills/painted-25d-world/_building-blocks/capture_paint_view.gd +32 -0
  165. package/library/skills/painted-25d-world/_building-blocks/paint_projection.gdshader +23 -0
  166. package/library/skills/painted-25d-world/_building-blocks/paintcam_check.gd +25 -0
  167. package/library/skills/painted-25d-world/_building-blocks/painted_door.gd +17 -0
  168. package/library/skills/painted-25d-world/_building-blocks/painted_location.gd +56 -0
  169. package/library/skills/painted-25d-world/_building-blocks/painted_player.gd +125 -0
  170. package/library/skills/painted-25d-world/_building-blocks/painted_world.gd +111 -0
  171. package/library/skills/painted-25d-world/_building-blocks/tools/cam.py +57 -0
  172. package/library/skills/painted-25d-world/_building-blocks/tools/detail4k.py +81 -0
  173. package/library/skills/painted-25d-world/_building-blocks/tools/overlay.py +17 -0
  174. package/library/skills/painted-25d-world/_building-blocks/tools/preview.py +60 -0
  175. package/library/skills/painted-25d-world/resource.yaml +39 -0
  176. package/library/skills/publish-your-game/SKILL.md +13 -14
  177. package/library/skills/publish-your-game/resource.yaml +4 -3
  178. package/library/skills/release-checklist/SKILL.md +188 -0
  179. package/library/skills/release-checklist/resource.yaml +40 -0
  180. package/library/skills/release-readiness/SKILL.md +1 -1
  181. package/library/skills/release-readiness/resource.yaml +1 -1
  182. package/library/skills/store-art/SKILL.md +1 -1
  183. package/library/skills/store-art/resource.yaml +1 -1
  184. package/library/skills/store-listing/SKILL.md +1 -1
  185. package/library/skills/store-listing/resource.yaml +1 -1
  186. package/library/skills/summer-cli/SKILL.md +2 -0
  187. package/library/skills/summer-store/SKILL.md +295 -39
  188. package/library/skills/summer-store/resource.yaml +9 -3
  189. package/library/skills/trailer-shot/SKILL.md +3 -3
  190. package/library/templates/README.md +5 -0
  191. package/library/tools/content-make/resource.yaml +132 -0
  192. package/library/tools/frame-shot/GUIDE.md +1 -1
  193. package/library/tools/go-live/resource.yaml +67 -0
  194. package/library/tools/import-from-url/GUIDE.md +1 -0
  195. package/library/tools/import-from-url-batch/GUIDE.md +1 -1
  196. package/library/tools/play/GUIDE.md +2 -0
  197. package/library/tools/projects-create-local/resource.yaml +55 -0
  198. package/library/tools/projects-open-local/resource.yaml +51 -0
  199. package/library/tools/publish-build/resource.yaml +3 -3
  200. package/library/tools/screenshot/GUIDE.md +1 -1
  201. package/library/tools/store-set-details/resource.yaml +114 -0
  202. package/library/tools/sync-items/resource.yaml +72 -0
  203. package/package.json +1 -1
  204. package/registry/generated/aliases.json +6 -0
  205. package/registry/generated/counts.json +3 -3
  206. package/registry/generated/gemini-extension.json +3 -1
  207. package/registry/generated/index.json +463 -37
  208. package/registry/generated/marketplace.claude.json +1 -1
  209. package/registry/generated/plugin.claude-directory.json +4 -2
  210. package/registry/generated/plugin.claude.json +3 -1
  211. package/registry/generated/plugin.codex.json +5 -3
  212. package/registry/generated/plugin.cursor.json +4 -2
  213. package/registry/generated/plugin.factory.json +4 -2
  214. package/registry/generated/server.json +2 -2
  215. package/registry/generated/skills-index.md +12 -8
  216. package/registry/generated/skills-registry.json +62 -5
  217. package/registry/operations/legacy-names.json +40 -0
  218. package/registry/operations/public-catalog.json +10144 -387
@@ -2,20 +2,33 @@
2
2
  * Usage beacons for the local MCP server. Fire-and-forget, never blocks, never throws.
3
3
  *
4
4
  * - One session ping per MCP server lifetime (mcp_session_start), on the
5
- * first tool call, with the host app's name from the MCP handshake.
5
+ * first tool call, with the host app's name from the MCP handshake and
6
+ * this machine's install id, so the anonymous first-run line (below) joins
7
+ * the account.
6
8
  * - One line per local tool call (mcp_tool_call): the tool name, the
7
9
  * Summer operation it ran, success, duration, the host app's name from the
8
- * MCP handshake, and the tool surface and profile. Nothing else: no
10
+ * MCP handshake, and the tool surface and profile, plus the closed-shape
11
+ * fields of usage-fields.ts (project id hash, template, game clients
12
+ * started, engine state on engine_not_running). Nothing else: no
9
13
  * arguments, no results, no project or file names.
14
+ * - Once per machine, the first `summer` command (any, `summer mcp` too)
15
+ * prints a short notice to stderr and sends one anonymous line
16
+ * (cli_first_run): the install id, the command name, the agent app and the
17
+ * operating system. It is the only line sent without an account.
10
18
  *
11
- * Both go to /api/mcp/log-local-call, only when signed in (the account token
12
- * attributes them), and never when SUMMER_NO_TELEMETRY=1 or DO_NOT_TRACK=1.
13
- * The endpoint always returns allowed:true and never gates; the response is
14
- * ignored.
19
+ * The session and call lines go to /api/mcp/log-local-call only when signed
20
+ * in (the account token attributes them). Nothing is sent when
21
+ * SUMMER_NO_TELEMETRY=1 or DO_NOT_TRACK=1. The endpoint always returns
22
+ * allowed:true and never gates; the response is ignored.
15
23
  */
16
- import { currentMcpClient, isTelemetryDisabled, summerRequestHeaders } from "./agent-host.js";
24
+ import { spawn } from "node:child_process";
25
+ import { fileURLToPath } from "node:url";
26
+ import { currentMcpClient, isTelemetryDisabled, resolveAgentHost, summerRequestHeaders } from "./agent-host.js";
17
27
  import { getAuthToken } from "./auth.js";
18
28
  import { resolveGatewayUrl } from "./config.js";
29
+ import { getInstallId } from "./feedback/client.js";
30
+ import { readStoreText, writeStoreText } from "./store.js";
31
+ import { usageLineFields } from "./usage-fields.js";
19
32
  export { isTelemetryDisabled };
20
33
  const TELEMETRY_PATH = "/api/mcp/log-local-call";
21
34
  const TIMEOUT_MS = 5000;
@@ -34,7 +47,10 @@ export function recordMcpSession() {
34
47
  return;
35
48
  pinged = true;
36
49
  const mcpClient = currentMcpClient()?.name;
37
- send({ client: "summer-cli", event: "mcp_session_start", ...(mcpClient ? { mcpClient: mcpClient.slice(0, 64) } : {}) });
50
+ void (async () => {
51
+ const installId = await getInstallId();
52
+ await postUsage({ client: "summer-cli", event: "mcp_session_start", ...(mcpClient ? { mcpClient: mcpClient.slice(0, 64) } : {}), installId });
53
+ })();
38
54
  }
39
55
  const ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/;
40
56
  /**
@@ -67,6 +83,7 @@ export function toolErrorCode(result, fallback) {
67
83
  export function recordLocalToolCall(call) {
68
84
  if (isTelemetryDisabled() || !TOOL_NAME.test(call.tool))
69
85
  return;
86
+ const error = !call.ok && call.error && ERROR_CODE.test(call.error) ? call.error : null;
70
87
  send({
71
88
  client: "summer-cli",
72
89
  event: "mcp_tool_call",
@@ -76,19 +93,20 @@ export function recordLocalToolCall(call) {
76
93
  ...(call.surfaceVersion ? { surfaceVersion: call.surfaceVersion } : {}),
77
94
  ...(call.profile ? { profile: call.profile } : {}),
78
95
  ok: call.ok,
79
- ...(!call.ok && call.error && ERROR_CODE.test(call.error) ? { error: call.error } : {}),
96
+ ...(error ? { error } : {}),
80
97
  durationMs: Math.max(0, Math.round(call.durationMs)),
81
- });
98
+ }, call.usage ?? {}, error);
82
99
  }
83
- function send(body) {
100
+ function send(body, usage, error) {
84
101
  // Void-discarded so the caller never accidentally awaits us.
85
- void postUsage(body);
102
+ void (async () => postUsage({ ...body, ...(await usageLineFields(usage, error)) }))();
86
103
  }
87
104
  /** POST one usage line with the account token; resolves when sent or given up. Never throws. */
88
- export async function postUsage(body, timeoutMs = TIMEOUT_MS) {
105
+ export async function postUsage(body, timeoutMs = TIMEOUT_MS, options = {}) {
89
106
  try {
90
- const token = await getAuthToken();
91
- if (!token)
107
+ // The first-run line is the one line sent without an account token.
108
+ const token = options.anonymous ? null : await getAuthToken();
109
+ if (!token && !options.anonymous)
92
110
  return; // unauthenticated usage is allowed; we just can't attribute it
93
111
  // Resolved per call, not at import: the gateway the token belongs to is
94
112
  // whatever env/config says NOW (see config.resolveGatewayUrl).
@@ -101,7 +119,7 @@ export async function postUsage(body, timeoutMs = TIMEOUT_MS) {
101
119
  signal: ctrl.signal,
102
120
  headers: {
103
121
  ...summerRequestHeaders(),
104
- Authorization: `Bearer ${token}`,
122
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
105
123
  "Content-Type": "application/json",
106
124
  },
107
125
  body: JSON.stringify(body),
@@ -115,6 +133,64 @@ export async function postUsage(body, timeoutMs = TIMEOUT_MS) {
115
133
  // never throws — telemetry is best-effort
116
134
  }
117
135
  }
136
+ const NOTICE_MARKER_FILE = "telemetry-notice";
137
+ const COMMAND = /^[a-z][a-z0-9_]{0,79}$/;
138
+ const BEACON = fileURLToPath(new URL("./cli-usage-beacon.js", import.meta.url));
139
+ /** The one-time notice, printed to stderr (never stdout: `summer mcp` speaks MCP there). */
140
+ export const TELEMETRY_NOTICE = [
141
+ "Summer collects usage data to improve its tools: which commands and tools run, whether they worked, and which agent app ran them.",
142
+ "No prompts, code, file names or project names. Opt out: SUMMER_NO_TELEMETRY=1",
143
+ "Details: https://github.com/summerengine/summer#telemetry",
144
+ ].join("\n");
145
+ /** CI runs and this repo's own tests are not people trying Summer. */
146
+ function automatedRun(env) {
147
+ const on = (value) => !!value && value !== "0" && value.toLowerCase() !== "false";
148
+ return on(env.CI) || on(env.VITEST);
149
+ }
150
+ /**
151
+ * The first `summer` command on this machine: print the notice once, then
152
+ * send one anonymous cli_first_run line (install id, command name, agent
153
+ * app, operating system). Nothing when opted out, in CI, or when the marker
154
+ * cannot be written (no line without the notice). Returns whether it ran.
155
+ */
156
+ export async function noteFirstRun(command, deps = {}) {
157
+ const env = deps.env ?? process.env;
158
+ try {
159
+ if (isTelemetryDisabled(env) || automatedRun(env))
160
+ return false;
161
+ if ((await readStoreText(NOTICE_MARKER_FILE)) !== null)
162
+ return false;
163
+ await writeStoreText(NOTICE_MARKER_FILE, new Date().toISOString());
164
+ (deps.write ?? ((text) => process.stderr.write(text)))(`${TELEMETRY_NOTICE}\n\n`);
165
+ const body = {
166
+ client: "summer-cli",
167
+ event: "cli_first_run",
168
+ installId: await getInstallId(),
169
+ ...(COMMAND.test(command) ? { command } : {}),
170
+ mcpClient: resolveAgentHost(env, undefined).id,
171
+ os: process.platform,
172
+ };
173
+ (deps.send ?? sendAnonymous)(body);
174
+ return true;
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ }
180
+ /** A detached child sends it, so no command waits for the network or loses it to process.exit(). */
181
+ function sendAnonymous(body) {
182
+ try {
183
+ spawn(process.execPath, [BEACON, JSON.stringify(body), "--anonymous"], {
184
+ detached: true,
185
+ stdio: "ignore",
186
+ windowsHide: true,
187
+ env: { ...process.env, NODE_OPTIONS: (process.env.NODE_OPTIONS ?? "").replace(/--inspect\S*/g, "").trim() },
188
+ }).unref();
189
+ }
190
+ catch {
191
+ // best-effort
192
+ }
193
+ }
118
194
  /**
119
195
  * Reset the once-per-session guard. Test-only.
120
196
  */
@@ -95,3 +95,23 @@ export interface MaterializedTemplate {
95
95
  * a half-fetched template never looks like a project.
96
96
  */
97
97
  export declare function materializePinnedTemplate(entry: TemplateEntry, opts: MaterializeOptions): MaterializedTemplate;
98
+ /** The codeload tarball URL for a github.com pin, or null when the pin is not
99
+ * a GitHub repo (then only git can fetch it). */
100
+ export declare function githubTarballUrl(pin: TemplatePin): string | null;
101
+ export interface FetchPinnedOptions extends MaterializeOptions {
102
+ /** The HTTP GET. Default: global fetch. Tests inject one. */
103
+ fetchTarball?: (url: string, signal: AbortSignal) => Promise<Response>;
104
+ /** Tarball URL for a pin, null for git only. Default: githubTarballUrl. Tests
105
+ * override it so a local (git-fetchable) pin can also take the tarball path. */
106
+ tarballUrl?: (pin: TemplatePin) => string | null;
107
+ }
108
+ /**
109
+ * Same contract as materializePinnedTemplate, without git when it can: a
110
+ * github.com pin is downloaded as the codeload tarball of exactly `commit`
111
+ * into a hidden sibling of `targetDir`, the tree digest is recomputed from the
112
+ * archive (same formula, same strict compare), and only a match is renamed
113
+ * into place. Any tarball failure removes the staging dir and falls back to
114
+ * the git path, so a mismatch still ends in git's own strict check.
115
+ * keepGit goes straight to git (a tarball has no .git).
116
+ */
117
+ export declare function fetchPinnedTemplate(entry: TemplateEntry, opts: FetchPinnedOptions): Promise<MaterializedTemplate>;
@@ -12,15 +12,21 @@
12
12
  * recompute `tree_digest` with the documented formula and REFUSE on any
13
13
  * mismatch, then check out the pinned tree (detached unless keepGit).
14
14
  *
15
+ * `fetchPinnedTemplate` first tries the same pin as a GitHub tarball (no git,
16
+ * so a clean Mac never sees the Command Line Tools dialog), verifies the same
17
+ * digest from the archive, and falls back to the git path on any failure.
18
+ *
15
19
  * A default branch is never consulted. Nothing here lists a GitHub org.
16
20
  *
17
21
  * Built-in templates (`builtin: true`) carry no pin; the CLI generates them
18
22
  * in-process. This module only identifies them.
19
23
  */
20
24
  import { execFileSync } from "node:child_process";
21
- import { createHash } from "node:crypto";
22
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
23
- import { join } from "node:path";
25
+ import { createHash, randomBytes } from "node:crypto";
26
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
27
+ import { basename, dirname, join, resolve } from "node:path";
28
+ import { pipeline, Readable } from "node:stream";
29
+ import { createGunzip } from "node:zlib";
24
30
  import { PACKAGE_ROOT } from "./package-root.js";
25
31
  const REGISTRY_RELPATH = join("registry", "generated", "templates-registry.json");
26
32
  let cache = null;
@@ -238,3 +244,294 @@ export function materializePinnedTemplate(entry, opts) {
238
244
  rmSync(join(opts.targetDir, ".git"), { recursive: true, force: true });
239
245
  return { commit: pin.commit, tree_digest: pin.tree_digest };
240
246
  }
247
+ // ---------- tarball (no git) ----------
248
+ /** The codeload tarball URL for a github.com pin, or null when the pin is not
249
+ * a GitHub repo (then only git can fetch it). */
250
+ export function githubTarballUrl(pin) {
251
+ const m = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(pin.repo);
252
+ if (!m || !/^[0-9a-f]{40}$/.test(pin.commit))
253
+ return null;
254
+ return `https://codeload.github.com/${m[1]}/${m[2]}/tar.gz/${pin.commit}`;
255
+ }
256
+ /**
257
+ * Same contract as materializePinnedTemplate, without git when it can: a
258
+ * github.com pin is downloaded as the codeload tarball of exactly `commit`
259
+ * into a hidden sibling of `targetDir`, the tree digest is recomputed from the
260
+ * archive (same formula, same strict compare), and only a match is renamed
261
+ * into place. Any tarball failure removes the staging dir and falls back to
262
+ * the git path, so a mismatch still ends in git's own strict check.
263
+ * keepGit goes straight to git (a tarball has no .git).
264
+ */
265
+ export async function fetchPinnedTemplate(entry, opts) {
266
+ const pin = entry.pin;
267
+ if (!pin)
268
+ throw new Error(`${entry.id} is a built-in template and has no pin to materialize.`);
269
+ if (existsSync(opts.targetDir))
270
+ throw new Error(`Directory already exists: ${opts.targetDir}`);
271
+ const url = opts.keepGit ? null : (opts.tarballUrl ?? githubTarballUrl)(pin);
272
+ if (!url)
273
+ return materializePinnedTemplate(entry, opts);
274
+ const log = opts.log ?? (() => { });
275
+ const target = resolve(opts.targetDir);
276
+ const staging = join(dirname(target), `.${basename(target)}.summer-download-${randomBytes(4).toString("hex")}`);
277
+ try {
278
+ mkdirSync(dirname(target), { recursive: true });
279
+ mkdirSync(staging);
280
+ log(`Downloading ${pin.repo} at ${pin.commit.slice(0, 12)} ...`);
281
+ const fetcher = opts.fetchTarball ?? ((u, signal) => fetch(u, { signal }));
282
+ const actual = await downloadTree(url, staging, fetcher);
283
+ if (actual !== pin.tree_digest) {
284
+ throw new Error(`tree digest mismatch: archive ${actual.slice(0, 12)}, pin ${pin.tree_digest.slice(0, 12)}`);
285
+ }
286
+ log(`Verified tree digest ${actual.slice(0, 12)}`);
287
+ if (existsSync(target))
288
+ throw new Error(`Directory already exists: ${opts.targetDir}`);
289
+ renameSync(staging, target);
290
+ return { commit: pin.commit, tree_digest: pin.tree_digest };
291
+ }
292
+ catch (err) {
293
+ rmSync(staging, { recursive: true, force: true });
294
+ const why = (err instanceof Error ? err.message : String(err)).replace(/\s+/g, " ");
295
+ log(`Tarball download failed (${why}); falling back to git.`);
296
+ return materializePinnedTemplate(entry, opts);
297
+ }
298
+ }
299
+ /** Download, gunzip and unpack a GitHub tarball into `staging` as a stream.
300
+ * fetchTimeoutSeconds() bounds each wait for bytes, not the whole download:
301
+ * a big template on a slow link keeps going while bytes arrive. Returns the
302
+ * tree digest of the archive. */
303
+ async function downloadTree(url, staging, fetcher) {
304
+ const seconds = fetchTimeoutSeconds();
305
+ const ctrl = new AbortController();
306
+ let timer = setTimeout(() => ctrl.abort(), seconds * 1000);
307
+ const stillGoing = () => {
308
+ clearTimeout(timer);
309
+ timer = setTimeout(() => ctrl.abort(), seconds * 1000);
310
+ };
311
+ const gunzip = createGunzip();
312
+ try {
313
+ const res = await fetcher(url, ctrl.signal);
314
+ if (res.status !== 200 || !res.body)
315
+ throw new Error(`HTTP ${res.status}`);
316
+ const body = Readable.fromWeb(res.body);
317
+ body.on("data", stillGoing);
318
+ // Errors on either side end the gunzip stream early; extractTar then fails.
319
+ pipeline(body, gunzip, () => { });
320
+ return await extractTar(gunzip, staging);
321
+ }
322
+ catch (err) {
323
+ if (ctrl.signal.aborted)
324
+ throw new Error(`no data for ${seconds}s`);
325
+ throw err;
326
+ }
327
+ finally {
328
+ clearTimeout(timer);
329
+ ctrl.abort();
330
+ gunzip.destroy();
331
+ }
332
+ }
333
+ /** Reads exact byte counts from a stream of chunks; one file at a time is the
334
+ * most it ever holds. */
335
+ function byteReader(source) {
336
+ const it = source[Symbol.asyncIterator]();
337
+ let parts = [];
338
+ let have = 0;
339
+ return async (n) => {
340
+ if (n === 0)
341
+ return Buffer.alloc(0);
342
+ while (have < n) {
343
+ const r = await it.next();
344
+ if (r.done)
345
+ throw new Error("archive ended early");
346
+ parts.push(r.value);
347
+ have += r.value.length;
348
+ }
349
+ const all = parts.length === 1 ? parts[0] : Buffer.concat(parts, have);
350
+ const rest = all.subarray(n);
351
+ parts = rest.length ? [rest] : [];
352
+ have = rest.length;
353
+ return all.subarray(0, n);
354
+ };
355
+ }
356
+ function cstr(b) {
357
+ const i = b.indexOf(0);
358
+ return i < 0 ? b : b.subarray(0, i);
359
+ }
360
+ /** A tar numeric field: octal text, or base-256 when the high bit is set. */
361
+ function tarNumber(field) {
362
+ if (field[0] & 0x80) {
363
+ if (field[0] === 0xff)
364
+ throw new Error("negative number in tar header");
365
+ let n = field[0] & 0x7f;
366
+ for (let i = 1; i < field.length; i++)
367
+ n = n * 256 + field[i];
368
+ if (!Number.isSafeInteger(n))
369
+ throw new Error("number too large in tar header");
370
+ return n;
371
+ }
372
+ const s = cstr(field).toString("latin1").trim();
373
+ if (s === "")
374
+ return 0;
375
+ if (!/^[0-7]+$/.test(s))
376
+ throw new Error(`bad number in tar header: ${JSON.stringify(s)}`);
377
+ return parseInt(s, 8);
378
+ }
379
+ /** pax records: "<len> <key>=<value>\n", values kept as bytes. */
380
+ function parsePax(body, into) {
381
+ let pos = 0;
382
+ while (pos < body.length) {
383
+ const sp = body.indexOf(0x20, pos);
384
+ const len = sp < 0 ? NaN : Number(body.subarray(pos, sp).toString("latin1"));
385
+ if (!Number.isSafeInteger(len) || pos + len > body.length || sp >= pos + len - 1) {
386
+ throw new Error("bad pax header");
387
+ }
388
+ const rec = body.subarray(sp + 1, pos + len - 1);
389
+ const eq = rec.indexOf(0x3d);
390
+ if (eq < 0)
391
+ throw new Error("bad pax header");
392
+ into.set(rec.subarray(0, eq).toString("utf8"), rec.subarray(eq + 1));
393
+ pos += len;
394
+ }
395
+ }
396
+ /** git's blob object id: sha1("blob <size>\0" + bytes). */
397
+ function blobId(data) {
398
+ return createHash("sha1").update(`blob ${data.length}\0`).update(data).digest("hex");
399
+ }
400
+ const C_ESCAPES = { 7: "a", 8: "b", 9: "t", 10: "n", 11: "v", 12: "f", 13: "r", 0x22: '"', 0x5c: "\\" };
401
+ /** A path as `git ls-tree` prints it by default (core.quotePath=true): C-quoted
402
+ * when it has a control byte, `"`, `\`, DEL or any byte >= 0x80. */
403
+ function quoteGitPath(path) {
404
+ if (!path.some((b) => b < 0x20 || b === 0x22 || b === 0x5c || b >= 0x7f))
405
+ return path;
406
+ let out = '"';
407
+ for (const b of path) {
408
+ if (C_ESCAPES[b])
409
+ out += "\\" + C_ESCAPES[b];
410
+ else if (b < 0x20 || b >= 0x7f)
411
+ out += "\\" + b.toString(8).padStart(3, "0");
412
+ else
413
+ out += String.fromCharCode(b);
414
+ }
415
+ return Buffer.from(out + '"', "latin1");
416
+ }
417
+ /**
418
+ * Unpack a git-archive tar (what codeload serves) into `staging` and return
419
+ * the documented tree digest of its contents. The top-level `<repo>-<sha>/`
420
+ * directory is stripped. Anything unexpected throws: entry types other than
421
+ * file/dir/symlink, absolute or `..` paths, a second top-level directory, a
422
+ * path through a symlink, or a duplicate path. Nothing is written outside
423
+ * `staging`.
424
+ */
425
+ async function extractTar(source, staging) {
426
+ const read = byteReader(source);
427
+ const readPadded = async (size) => {
428
+ const data = await read(size);
429
+ await read((512 - (size % 512)) % 512);
430
+ return data;
431
+ };
432
+ const lines = [];
433
+ const realDirs = new Set([staging]);
434
+ // Create each missing directory under staging; refuse to pass through a
435
+ // symlink or a file (lstat asks the filesystem, so case folding is covered).
436
+ const ensureDir = (parts) => {
437
+ let p = staging;
438
+ for (const part of parts) {
439
+ p = join(p, part);
440
+ if (realDirs.has(p))
441
+ continue;
442
+ const st = lstatSync(p, { throwIfNoEntry: false });
443
+ if (!st)
444
+ mkdirSync(p);
445
+ else if (!st.isDirectory())
446
+ throw new Error(`archive path passes through a non-directory: ${parts.join("/")}`);
447
+ realDirs.add(p);
448
+ }
449
+ };
450
+ let root = null;
451
+ let pax = new Map();
452
+ let longName = null;
453
+ let longLink = null;
454
+ for (;;) {
455
+ const h = await read(512);
456
+ if (h.every((b) => b === 0)) {
457
+ if (!(await read(512)).every((b) => b === 0))
458
+ throw new Error("bad tar end marker");
459
+ break;
460
+ }
461
+ let sum = 0;
462
+ for (let i = 0; i < 512; i++)
463
+ sum += i >= 148 && i < 156 ? 0x20 : h[i];
464
+ if (sum !== tarNumber(h.subarray(148, 156)))
465
+ throw new Error("bad tar header checksum");
466
+ const type = h[156] === 0 ? "0" : String.fromCharCode(h[156]);
467
+ let size = tarNumber(h.subarray(124, 136));
468
+ if (type === "x" || type === "g" || type === "L" || type === "K") {
469
+ const body = await readPadded(size);
470
+ if (type === "x")
471
+ parsePax(body, pax);
472
+ else if (type === "L")
473
+ longName = cstr(body);
474
+ else if (type === "K")
475
+ longLink = cstr(body);
476
+ continue; // "g": GitHub puts the commit id there; nothing to apply
477
+ }
478
+ const ustar = h.subarray(257, 263).toString("latin1") === "ustar\0";
479
+ const prefix = ustar ? cstr(h.subarray(345, 500)) : Buffer.alloc(0);
480
+ const shortName = cstr(h.subarray(0, 100));
481
+ const name = pax.get("path") ?? longName ?? (prefix.length ? Buffer.concat([prefix, Buffer.from("/"), shortName]) : shortName);
482
+ const link = pax.get("linkpath") ?? longLink ?? cstr(h.subarray(157, 257));
483
+ const paxSize = pax.get("size");
484
+ if (paxSize) {
485
+ size = Number(paxSize.toString("latin1"));
486
+ if (!Number.isSafeInteger(size) || size < 0)
487
+ throw new Error("bad pax size");
488
+ }
489
+ pax = new Map();
490
+ longName = longLink = null;
491
+ // Strip the single top-level `<repo>-<sha>/` directory.
492
+ const slash = name.indexOf(0x2f);
493
+ const top = slash < 0 ? name : name.subarray(0, slash);
494
+ if (root === null)
495
+ root = top;
496
+ if (!top.equals(root) || top.length === 0)
497
+ throw new Error("archive is not a single top-level directory");
498
+ let rel = slash < 0 ? Buffer.alloc(0) : name.subarray(slash + 1);
499
+ while (rel.length && rel[rel.length - 1] === 0x2f)
500
+ rel = rel.subarray(0, rel.length - 1);
501
+ const parts = rel.length ? rel.toString("utf8").split("/") : [];
502
+ for (const part of parts) {
503
+ if (part === "" || part === "." || part === ".." || (process.platform === "win32" && /[\\:]/.test(part))) {
504
+ throw new Error(`unsafe path in archive: ${JSON.stringify(rel.toString("utf8"))}`);
505
+ }
506
+ }
507
+ if (parts.length === 0 && type !== "5")
508
+ throw new Error("file outside the archive's top-level directory");
509
+ const dest = join(staging, ...parts);
510
+ if (type === "0") {
511
+ const data = await readPadded(size);
512
+ ensureDir(parts.slice(0, -1));
513
+ const mode = tarNumber(h.subarray(100, 108)) & 0o111 ? 0o755 : 0o644;
514
+ writeFileSync(dest, data, { flag: "wx", mode }); // wx: never follows or overwrites
515
+ lines.push({ path: rel, oid: blobId(data) });
516
+ }
517
+ else if (type === "5") {
518
+ await readPadded(size);
519
+ ensureDir(parts);
520
+ }
521
+ else if (type === "2") {
522
+ await readPadded(size);
523
+ ensureDir(parts.slice(0, -1));
524
+ symlinkSync(link.toString("utf8"), dest);
525
+ lines.push({ path: rel, oid: blobId(link) });
526
+ }
527
+ else {
528
+ throw new Error(`unsupported tar entry type ${JSON.stringify(type)}`);
529
+ }
530
+ }
531
+ // Bytewise path order equals `git ls-tree -r` order.
532
+ lines.sort((a, b) => Buffer.compare(a.path, b.path));
533
+ const hash = createHash("sha256");
534
+ for (const l of lines)
535
+ hash.update(`${l.oid} `).update(quoteGitPath(l.path)).update("\n");
536
+ return hash.digest("hex");
537
+ }
@@ -4,11 +4,11 @@
4
4
  * which writes a profile into an agent's MCP entry. `full` is today's
5
5
  * surface exactly and the default, so existing configs keep it.
6
6
  */
7
- export declare const TOOLSETS: readonly ["core", "assets", "tools", "engine", "ui", "ship", "uploads", "apps", "board", "grow", "studio", "cloud", "other"];
7
+ export declare const TOOLSETS: readonly ["core", "assets", "tools", "engine", "ui", "ship", "uploads", "apps", "board", "grow", "studio", "cloud", "projects", "other"];
8
8
  export type Toolset = (typeof TOOLSETS)[number];
9
9
  export declare const PROFILES: {
10
10
  /** Every tool, local and hosted (today's surface). */
11
- readonly full: readonly ["core", "assets", "tools", "engine", "ui", "ship", "uploads", "apps", "board", "grow", "studio", "cloud", "other"];
11
+ readonly full: readonly ["core", "assets", "tools", "engine", "ui", "ship", "uploads", "apps", "board", "grow", "studio", "cloud", "projects", "other"];
12
12
  /** Summer Engine creators: engine, generation and the store. Fits clients with a 128-tool limit. */
13
13
  readonly engine: readonly ["core", "assets", "engine", "ship"];
14
14
  /** Any engine or none: generation, the asset library, account and library search. No engine tools. */
@@ -17,6 +17,7 @@ export const TOOLSETS = [
17
17
  "grow",
18
18
  "studio",
19
19
  "cloud",
20
+ "projects",
20
21
  "other",
21
22
  ];
22
23
  export const PROFILES = {
@@ -0,0 +1,41 @@
1
+ export interface UsageFields {
2
+ projectIdHash?: string;
3
+ templateId?: string;
4
+ templateTags?: string[];
5
+ instanceCount?: number;
6
+ }
7
+ export interface EngineState {
8
+ desktopInstalled: boolean;
9
+ engineRunning: boolean;
10
+ projectOpen: boolean;
11
+ signedIn: boolean;
12
+ }
13
+ /** Only the closed shapes above survive; everything else is dropped. */
14
+ export declare function cleanUsageFields(fields: Record<string, unknown>): UsageFields;
15
+ /** A tool or command noted fields for its usage line; never throws. */
16
+ export declare function noteToolUsage(fields: UsageFields): void;
17
+ /** The fields noted in this process (the CLI line), with the bound project. */
18
+ export declare function processUsageFields(): UsageFields;
19
+ /** The engine connection bound this project (api-client.ts connect and rebind). */
20
+ export declare function noteBoundProject(projectIdHash: string | undefined): void;
21
+ export declare function boundProject(): string | undefined;
22
+ /** The library slug for a catalog template id or slug, else null. */
23
+ export declare function catalogTemplateSlug(idOrSlug: string): string | null;
24
+ /** A catalog template's library domains (`facets.domains`), e.g. ["2d", "platformer"]. */
25
+ export declare function templateTags(slug: string): string[];
26
+ /**
27
+ * The catalog template the project with this hash started from: the running
28
+ * editor's registry entry gives its folder, `.summer/project.json` the
29
+ * template. Cached per hash; null when unknown or not a catalog template.
30
+ */
31
+ export declare function templateForProject(projectIdHash: string): Promise<string | null>;
32
+ /** What is true on this machine when the engine could not be reached. Local files only. */
33
+ export declare function engineState(): Promise<EngineState>;
34
+ /**
35
+ * The extra fields of one usage line: what the call noted, else the bound
36
+ * project and its template, and the engine state for engine_not_running.
37
+ * Never throws.
38
+ */
39
+ export declare function usageLineFields(noted: UsageFields, error?: string | null): Promise<Record<string, unknown>>;
40
+ /** Test-only: forget notes, the bound project and the template cache. */
41
+ export declare function _resetUsageFieldsForTests(): void;