@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * `make_wavetable(file, name, {...})`: turns any audio file in the project
3
+ * (a download, a stem, an imported sample) into a wavetable at
4
+ * `tracks/<slug>/wavetables/<name>.wav` — float32 frames of 2048 samples
5
+ * with a `clm ` chunk, the format Serum, Vital and uzu-wavetables use — and
6
+ * reports how the timbre moves across it so the model can pick a position.
7
+ *
8
+ * WAV inputs are read directly; anything else goes through ffmpeg to
9
+ * 48 kHz mono float. The extraction itself is `src/audio/wavetable-maker.ts`.
10
+ */
11
+ import { rename, stat } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { SCORE_LIMITS } from "../../core/score.ts";
14
+ import {
15
+ MAKE_FRAME_SIZE,
16
+ WavetableMakeError,
17
+ encodeWavetableWav,
18
+ makeWavetable,
19
+ type MakeMethod,
20
+ type MakeNormalize,
21
+ } from "../audio/wavetable-maker.ts";
22
+ import { missingTool } from "./backend.ts";
23
+ import {
24
+ ensureDir,
25
+ formatBytes,
26
+ projectPath,
27
+ resolveInput,
28
+ sha256File,
29
+ } from "./paths.ts";
30
+ import { MediaToolError, runHelper } from "./process.ts";
31
+ import {
32
+ MEDIA_LIMITS,
33
+ type MediaHost,
34
+ type MediaResult,
35
+ type MediaRunContext,
36
+ } from "./types.ts";
37
+ import { parseWav } from "./vendor/wav.ts";
38
+
39
+ /** Longest source read; the maker only analyses a bounded region of it. */
40
+ const MAX_SOURCE_SECONDS = 10 * 60;
41
+ const DECODE_RATE = 48_000;
42
+
43
+ export type MakeWavetableArgs = Readonly<{
44
+ file: string;
45
+ name: string;
46
+ frames?: number;
47
+ start?: number;
48
+ end?: number;
49
+ method?: MakeMethod;
50
+ smooth?: number;
51
+ normalize?: MakeNormalize;
52
+ }>;
53
+
54
+ export function wavetablesDir(
55
+ host: Pick<MediaHost, "projectRoot" | "trackSlug">,
56
+ ): string {
57
+ return join(host.projectRoot, "tracks", host.trackSlug, "wavetables");
58
+ }
59
+
60
+ function mixdown(
61
+ wav: ReturnType<typeof parseWav>,
62
+ maxFrames: number,
63
+ ): Float32Array {
64
+ const frames = Math.min(wav.sampleCount, maxFrames);
65
+ const out = new Float32Array(frames);
66
+ for (let i = 0; i < frames; i += 1) {
67
+ let sum = 0;
68
+ for (let c = 0; c < wav.channels; c += 1) sum += wav.sample(i, c);
69
+ out[i] = sum / wav.channels;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ async function readSource(
75
+ context: MediaRunContext,
76
+ absolute: string,
77
+ size: number,
78
+ ): Promise<{ mono: Float32Array; sampleRate: number }> {
79
+ const options = {
80
+ maximumBytes: 2 ** 31,
81
+ maximumDurationSeconds: 24 * 3600,
82
+ maximumChannels: 32,
83
+ };
84
+ if (/\.wav$/i.test(absolute) && size <= MEDIA_LIMITS.downloadMaxBytes) {
85
+ try {
86
+ const wav = parseWav(
87
+ new Uint8Array(await Bun.file(absolute).arrayBuffer()),
88
+ options,
89
+ );
90
+ return {
91
+ mono: mixdown(wav, MAX_SOURCE_SECONDS * wav.sampleRate),
92
+ sampleRate: wav.sampleRate,
93
+ };
94
+ } catch {
95
+ // Unusual WAV (ADPCM, extensible quirks): let ffmpeg read it.
96
+ }
97
+ }
98
+ if (!context.runner.which("ffmpeg"))
99
+ throw new MediaToolError(missingTool("ffmpeg"));
100
+ const temp = join(
101
+ await ensureDir(wavetablesDir(context)),
102
+ `.decode-${process.pid}-${Date.now()}.part.wav`,
103
+ );
104
+ context.progress("ffmpeg → mono 48 kHz");
105
+ try {
106
+ await runHelper(
107
+ context,
108
+ [
109
+ "ffmpeg",
110
+ "-hide_banner",
111
+ "-nostdin",
112
+ "-loglevel",
113
+ "error",
114
+ "-y",
115
+ "-i",
116
+ absolute,
117
+ "-vn",
118
+ "-t",
119
+ String(MAX_SOURCE_SECONDS),
120
+ "-ar",
121
+ String(DECODE_RATE),
122
+ "-ac",
123
+ "1",
124
+ "-c:a",
125
+ "pcm_f32le",
126
+ temp,
127
+ ],
128
+ { timeoutMs: MEDIA_LIMITS.sampleTimeoutMs },
129
+ );
130
+ const wav = parseWav(
131
+ new Uint8Array(await Bun.file(temp).arrayBuffer()),
132
+ options,
133
+ );
134
+ return { mono: mixdown(wav, wav.sampleCount), sampleRate: wav.sampleRate };
135
+ } finally {
136
+ await Bun.file(temp)
137
+ .delete()
138
+ .catch(() => undefined);
139
+ }
140
+ }
141
+
142
+ export async function makeWavetableFile(
143
+ args: MakeWavetableArgs,
144
+ context: MediaRunContext,
145
+ ): Promise<MediaResult> {
146
+ const input = await resolveInput(context, args.file);
147
+ const { mono, sampleRate } = await readSource(
148
+ context,
149
+ input.absolute,
150
+ input.size,
151
+ );
152
+ context.progress(`analysing ${input.relative}`);
153
+ let made;
154
+ try {
155
+ made = makeWavetable(mono, sampleRate, {
156
+ ...(args.frames !== undefined ? { frames: args.frames } : {}),
157
+ ...(args.start !== undefined ? { start: args.start } : {}),
158
+ ...(args.end !== undefined ? { end: args.end } : {}),
159
+ ...(args.method !== undefined ? { method: args.method } : {}),
160
+ ...(args.smooth !== undefined ? { smooth: args.smooth } : {}),
161
+ ...(args.normalize !== undefined ? { normalize: args.normalize } : {}),
162
+ });
163
+ } catch (error) {
164
+ if (error instanceof WavetableMakeError)
165
+ throw new MediaToolError(error.message);
166
+ throw error;
167
+ }
168
+ const bytes = encodeWavetableWav(made.frames);
169
+ if (bytes.byteLength > SCORE_LIMITS.maxSampleFileBytes)
170
+ throw new MediaToolError(
171
+ `${formatBytes(bytes.byteLength)} is over the sample file limit; use fewer frames`,
172
+ );
173
+ const dir = await ensureDir(wavetablesDir(context));
174
+ const target = join(dir, `${args.name}.wav`);
175
+ const temp = `${target}.part`;
176
+ await Bun.write(temp, bytes);
177
+ await rename(temp, target);
178
+ const sha256 = await sha256File(target);
179
+ const path = projectPath(context.projectRoot, target);
180
+ const size = (await stat(target)).size;
181
+ const region = `${made.region.start.toFixed(2)}–${made.region.end.toFixed(2)} s${made.region.auto ? " (auto)" : ""}`;
182
+ return {
183
+ summary: `wavetable ${args.name} · ${made.frames.length} frames · ${made.method} · ${region}`,
184
+ outputs: [path],
185
+ content: {
186
+ wavetable: {
187
+ name: args.name,
188
+ path,
189
+ sha256,
190
+ frames: made.frames.length,
191
+ frameSize: MAKE_FRAME_SIZE,
192
+ bytes: size,
193
+ source: input.relative,
194
+ method: made.method,
195
+ region: made.region,
196
+ ...(made.pitch ? { pitch: made.pitch } : {}),
197
+ },
198
+ sweep: made.sweep,
199
+ next: `set_wavetable with table "${path}" plays it on a track (wt 0..1 picks the frame; wtenv or set_automation wt sweeps it). In track.ts: instrument: wavetable("./wavetables/${args.name}.wav").`,
200
+ },
201
+ };
202
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `dawg check`: typecheck the project, evaluate `song.ts`, print every
3
+ * diagnostic as `file:line:col message`, exit 1 on any failure.
4
+ */
5
+
6
+ import {
7
+ evaluateProject,
8
+ formatDiagnostic,
9
+ type Diagnostic,
10
+ } from "../../core/sdk/eval.ts";
11
+ import { isProject } from "./init.ts";
12
+ import { typecheckProject } from "./typecheck.ts";
13
+
14
+ export type CheckDeps = Readonly<{
15
+ typecheck?: typeof typecheckProject;
16
+ evaluate?: typeof evaluateProject;
17
+ }>;
18
+
19
+ export type CheckReport = Readonly<{
20
+ ok: boolean;
21
+ diagnostics: readonly Diagnostic[];
22
+ typesMs: number;
23
+ evalMs: number;
24
+ tracks: number;
25
+ notes: number;
26
+ }>;
27
+
28
+ /** Runs both checks on `project`; never throws for project problems. */
29
+ export async function checkProject(
30
+ project: string,
31
+ deps: CheckDeps = {},
32
+ ): Promise<CheckReport> {
33
+ const [types, evaluated] = await Promise.all([
34
+ (deps.typecheck ?? typecheckProject)(project),
35
+ (deps.evaluate ?? evaluateProject)(project),
36
+ ]);
37
+ const diagnostics = [
38
+ ...types.diagnostics,
39
+ ...(evaluated.ok ? [] : evaluated.diagnostics),
40
+ ];
41
+ return Object.freeze({
42
+ ok: types.ok && evaluated.ok,
43
+ diagnostics: Object.freeze(diagnostics),
44
+ typesMs: types.ms,
45
+ evalMs: evaluated.ms,
46
+ tracks: evaluated.ok ? evaluated.score.tracks.length : 0,
47
+ notes: evaluated.ok ? evaluated.score.notes.length : 0,
48
+ });
49
+ }
50
+
51
+ export async function runCheckCommand(
52
+ argv: readonly string[],
53
+ cwd: string,
54
+ stdout: { write(text: string): unknown },
55
+ stderr: { write(text: string): unknown },
56
+ deps: CheckDeps = {},
57
+ ): Promise<number> {
58
+ const project = cwd;
59
+ if (!(await isProject(project))) {
60
+ stderr.write(
61
+ "not a dawg project (no dawg.json here); run `dawg init` first\n",
62
+ );
63
+ return 1;
64
+ }
65
+ const report = await checkProject(project, deps);
66
+ for (const diagnostic of report.diagnostics)
67
+ stderr.write(`${formatDiagnostic(diagnostic)}\n`);
68
+ if (report.ok) {
69
+ stdout.write(
70
+ `ok · ${report.tracks} track${report.tracks === 1 ? "" : "s"}, ${report.notes} note${
71
+ report.notes === 1 ? "" : "s"
72
+ } · types ${report.typesMs} ms · eval ${report.evalMs} ms\n`,
73
+ );
74
+ return 0;
75
+ }
76
+ const count = report.diagnostics.length;
77
+ stderr.write(`${count} problem${count === 1 ? "" : "s"}\n`);
78
+ void argv;
79
+ return 1;
80
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Project layout on disk:
3
+ *
4
+ * dawg.json project marker: format and SDK major
5
+ * tsconfig.json strict, `"dawg"` → `.dawg/sdk/v1.ts`
6
+ * song.ts `export default song({ ..., tracks: [...] })`
7
+ * tracks/<slug>/track.ts one `track({...})` per track (+ `samples/`)
8
+ * .dawg/sdk/v1.ts vendored SDK, refreshed when the shipped copy is newer
9
+ * .dawg/sdk/tsconfig.json compiler options the root config extends
10
+ * .dawg/tsbuild/ incremental typecheck state
11
+ * .dawg/sync.json hashes of files dawg wrote (echo suppression)
12
+ *
13
+ * `initProject` is idempotent, writes only under `dir`, and reports each
14
+ * file it created or refreshed exactly once.
15
+ */
16
+
17
+ import { createHash } from "node:crypto";
18
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ export const PROJECT_FILE = "dawg.json";
23
+ export const PROJECT_FORMAT = "dawg.project/v1";
24
+ export const SDK_RELATIVE_PATH = ".dawg/sdk/v1.ts";
25
+ export const SDK_TSCONFIG_RELATIVE_PATH = ".dawg/sdk/tsconfig.json";
26
+
27
+ /** The SDK source dawg ships (`core/sdk/v1.ts`). */
28
+ export const SHIPPED_SDK_PATH = join(
29
+ dirname(fileURLToPath(import.meta.url)),
30
+ "..",
31
+ "..",
32
+ "core",
33
+ "sdk",
34
+ "v1.ts",
35
+ );
36
+
37
+ export type ProjectManifest = Readonly<{ format: string; sdk: number }>;
38
+
39
+ export type InitResult = Readonly<{
40
+ /** Project-relative paths created or refreshed, in write order. */
41
+ wrote: readonly string[];
42
+ /** SDK version now vendored in the project. */
43
+ sdkVersion: string;
44
+ }>;
45
+
46
+ const SDK_TSCONFIG = `${JSON.stringify(
47
+ {
48
+ compilerOptions: {
49
+ target: "ES2022",
50
+ module: "ESNext",
51
+ moduleResolution: "Bundler",
52
+ strict: true,
53
+ noUncheckedIndexedAccess: true,
54
+ noEmit: true,
55
+ skipLibCheck: true,
56
+ allowImportingTsExtensions: true,
57
+ types: [],
58
+ },
59
+ },
60
+ null,
61
+ 2,
62
+ )}\n`;
63
+
64
+ const ROOT_TSCONFIG = `${JSON.stringify(
65
+ {
66
+ extends: "./.dawg/sdk/tsconfig.json",
67
+ compilerOptions: { paths: { dawg: ["./.dawg/sdk/v1.ts"] } },
68
+ include: ["song.ts", "tracks/**/*.ts", ".dawg/sdk/*.ts"],
69
+ },
70
+ null,
71
+ 2,
72
+ )}\n`;
73
+
74
+ const EMPTY_SONG = `import { song } from "dawg";
75
+
76
+ export default song({
77
+ tempo: 120,
78
+ meter: [4, 4],
79
+ bars: 4,
80
+ tracks: [],
81
+ });
82
+ `;
83
+
84
+ const GITIGNORE_LINES = [".dawg/*", "!.dawg/sdk/"];
85
+
86
+ /** True when `dir` holds a `dawg.json`. */
87
+ export async function isProject(dir: string): Promise<boolean> {
88
+ return (await readManifest(dir)) !== undefined;
89
+ }
90
+
91
+ /** Parses `dawg.json` from unknown; undefined when absent or unreadable. */
92
+ export async function readManifest(
93
+ dir: string,
94
+ ): Promise<ProjectManifest | undefined> {
95
+ let text: string;
96
+ try {
97
+ text = await readFile(join(dir, PROJECT_FILE), "utf8");
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ if (text.length > 4096) return undefined;
102
+ try {
103
+ const value: unknown = JSON.parse(text);
104
+ if (typeof value !== "object" || value === null) return undefined;
105
+ const record = value as Record<string, unknown>;
106
+ if (record.format !== PROJECT_FORMAT) return undefined;
107
+ if (typeof record.sdk !== "number" || !Number.isInteger(record.sdk))
108
+ return undefined;
109
+ return Object.freeze({ format: PROJECT_FORMAT, sdk: record.sdk });
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ /** Version string declared by an SDK source file, or undefined. */
116
+ export function sdkVersionOf(source: string): string | undefined {
117
+ const match = source.match(/SDK_VERSION = "(\d+)\.(\d+)\.(\d+)"/);
118
+ return match ? `${match[1]}.${match[2]}.${match[3]}` : undefined;
119
+ }
120
+
121
+ function newer(candidate: string, current: string | undefined): boolean {
122
+ if (current === undefined) return true;
123
+ const a = candidate.split(".").map(Number);
124
+ const b = current.split(".").map(Number);
125
+ if (a[0] !== b[0]) return false; // a different major is never auto-applied
126
+ for (let index = 1; index < 3; index += 1)
127
+ if (a[index]! !== b[index]!) return a[index]! > b[index]!;
128
+ return false;
129
+ }
130
+
131
+ export type InitOptions = Readonly<{
132
+ /** SDK source to vendor; defaults to the shipped `core/sdk/v1.ts`. */
133
+ sdkSource?: string;
134
+ }>;
135
+
136
+ /**
137
+ * Creates or refreshes the project in `dir`. Existing `song.ts`,
138
+ * `tsconfig.json`, `.gitignore` entries and `dawg.json` are left alone;
139
+ * the vendored SDK is replaced only by a newer copy of the same major.
140
+ */
141
+ export async function initProject(
142
+ dir: string,
143
+ options: InitOptions = {},
144
+ ): Promise<InitResult> {
145
+ const wrote: string[] = [];
146
+ const sdkSource =
147
+ options.sdkSource ?? (await readFile(SHIPPED_SDK_PATH, "utf8"));
148
+ const shippedVersion = sdkVersionOf(sdkSource);
149
+ if (!shippedVersion) throw new Error("shipped SDK has no SDK_VERSION");
150
+ const major = Number(shippedVersion.split(".")[0]);
151
+
152
+ await mkdir(join(dir, ".dawg", "sdk"), { recursive: true });
153
+ await mkdir(join(dir, "tracks"), { recursive: true });
154
+
155
+ const vendored = await readOptional(join(dir, SDK_RELATIVE_PATH));
156
+ const vendoredVersion =
157
+ vendored === undefined ? undefined : sdkVersionOf(vendored);
158
+ let sdkVersion = vendoredVersion ?? shippedVersion;
159
+ if (vendored === undefined || newer(shippedVersion, vendoredVersion)) {
160
+ await writeAtomic(join(dir, SDK_RELATIVE_PATH), sdkSource);
161
+ wrote.push(SDK_RELATIVE_PATH);
162
+ sdkVersion = shippedVersion;
163
+ }
164
+ if (
165
+ (await readOptional(join(dir, SDK_TSCONFIG_RELATIVE_PATH))) !== SDK_TSCONFIG
166
+ ) {
167
+ await writeAtomic(join(dir, SDK_TSCONFIG_RELATIVE_PATH), SDK_TSCONFIG);
168
+ wrote.push(SDK_TSCONFIG_RELATIVE_PATH);
169
+ }
170
+ if ((await readManifest(dir)) === undefined) {
171
+ await writeAtomic(
172
+ join(dir, PROJECT_FILE),
173
+ `${JSON.stringify({ format: PROJECT_FORMAT, sdk: major }, null, 2)}\n`,
174
+ );
175
+ wrote.push(PROJECT_FILE);
176
+ }
177
+ if ((await readOptional(join(dir, "tsconfig.json"))) === undefined) {
178
+ await writeAtomic(join(dir, "tsconfig.json"), ROOT_TSCONFIG);
179
+ wrote.push("tsconfig.json");
180
+ }
181
+ if ((await readOptional(join(dir, "song.ts"))) === undefined) {
182
+ await writeAtomic(join(dir, "song.ts"), EMPTY_SONG);
183
+ // Recorded as dawg-written so the first window lets the session win.
184
+ await writeAtomic(
185
+ join(dir, ".dawg", "sync.json"),
186
+ `${JSON.stringify(
187
+ {
188
+ files: {
189
+ "song.ts": createHash("sha256").update(EMPTY_SONG).digest("hex"),
190
+ },
191
+ },
192
+ null,
193
+ 2,
194
+ )}\n`,
195
+ );
196
+ wrote.push("song.ts");
197
+ }
198
+ const gitignore = (await readOptional(join(dir, ".gitignore"))) ?? "";
199
+ const present = new Set(gitignore.split("\n").map((line) => line.trim()));
200
+ const missing = GITIGNORE_LINES.filter((line) => !present.has(line));
201
+ if (missing.length > 0) {
202
+ const prefix =
203
+ gitignore.length === 0 || gitignore.endsWith("\n") ? "" : "\n";
204
+ await writeAtomic(
205
+ join(dir, ".gitignore"),
206
+ `${gitignore}${prefix}${missing.join("\n")}\n`,
207
+ );
208
+ wrote.push(".gitignore");
209
+ }
210
+ return Object.freeze({ wrote: Object.freeze(wrote), sdkVersion });
211
+ }
212
+
213
+ /** `dawg init [dir]`: creates the project and prints what it wrote. */
214
+ export async function runInitCommand(
215
+ argv: readonly string[],
216
+ cwd: string,
217
+ stdout: { write(text: string): unknown },
218
+ stderr: { write(text: string): unknown },
219
+ ): Promise<number> {
220
+ const target =
221
+ argv[1] && !argv[1].startsWith("--") ? resolve(cwd, argv[1]) : cwd;
222
+ try {
223
+ const result = await initProject(target);
224
+ if (result.wrote.length === 0)
225
+ stdout.write(`project up to date (sdk ${result.sdkVersion})\n`);
226
+ else for (const path of result.wrote) stdout.write(`wrote ${path}\n`);
227
+ return 0;
228
+ } catch (error) {
229
+ stderr.write(
230
+ `init failed: ${error instanceof Error ? error.message : String(error)}\n`,
231
+ );
232
+ return 1;
233
+ }
234
+ }
235
+
236
+ async function readOptional(path: string): Promise<string | undefined> {
237
+ try {
238
+ return await readFile(path, "utf8");
239
+ } catch {
240
+ return undefined;
241
+ }
242
+ }
243
+
244
+ /** Temp file + rename so a reader never sees a partial file. */
245
+ export async function writeAtomic(
246
+ path: string,
247
+ contents: string,
248
+ ): Promise<void> {
249
+ await mkdir(dirname(path), { recursive: true });
250
+ const temporary = `${path}.${process.pid}.tmp`;
251
+ await writeFile(temporary, contents, "utf8");
252
+ await rename(temporary, path);
253
+ }