-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathtypescript-sdk.dang
More file actions
1990 lines (1776 loc) · 83.5 KB
/
Copy pathtypescript-sdk.dang
File metadata and controls
1990 lines (1776 loc) · 83.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
enum Runtime {
NODE
BUN
DENO
}
type TypescriptSdk {
# Matches a legacy dagger.json declaring the TypeScript SDK, in either
# spelling: the object form `"sdk": { "source": "typescript" }` and the older
# bare string `"sdk": "typescript"` are both valid pre-1.0 config, and a
# module written the second way is one this SDK owns just as much.
let tsPattern = "\"sdk\"\\s*:\\s*(\"typescript\"|\\{[^}]*\"source\"\\s*:\\s*\"typescript\")"
# Matches a `[runtime]` table with `source = "typescript"` in a CLI 1.0
# dagger-module.toml. [^\[]* keeps the match inside the runtime table (it stops
# at the next [section]).
let tsTomlPattern = "\\[runtime\\][^\\[]*source\\s*=\\s*\"typescript\""
"""
JavaScript runtime for the code this SDK generates: "node", "bun" or "deno".
Empty detects it from the config files already in the scope — deno.json for
Deno, a bun lockfile for Bun, otherwise Node — so a scope keeps the runtime it
was created with. Setting it moves the scope to that runtime on the next
generation.
"""
runtime: String! = ""
"""
Starter to materialize when a scope has no module yet: `default` for a small
working module, `empty` for a bare @object class.
"""
template: String! = "default"
"""Write selected Git commits as manifest pins."""
lock: Boolean! = false
"""
Declare a module entrypoint instead of a legacy runtime.
Experimental, and engine-gated: only an engine carrying dagger/dagger#14038
loads a module this way. The setting selects the whole loading path, not just
the manifest: an entrypoint module gets an `[entrypoint]` manifest,
entrypoint/main.dang and the __dagger.dispatch.ts that program shells out to,
while a `[runtime]` module gets none of those and keeps the
__dagger.entrypoint.ts its runtime execs. No module is generated with both
halves.
Turning it on is not free. The manifest comes out with an `[entrypoint]` table
and no `[runtime]`, and `engineVersion`, `source` and `[[dependencies]]` are
fields of the runtime the module no longer declares — so a module that had
them loses them. Leave it off unless you are testing the entrypoint path.
A scope's clients still work. With no `[[dependencies]]` to declare them in,
the dispatcher serves them into its own session at run time instead — see
serveBoundModules in the generated __dagger.dispatch.ts.
"""
entrypoint: Boolean! = false
"""
packageManager pin for package.json, as `name@version` or just `name`. Empty
writes no field. Node only; Bun and Deno bundle their own.
"""
packageManager: String! = ""
"""
Base container image modules build on. Empty keeps the SDK default. Written to
deno.json for the Deno runtime, package.json otherwise.
"""
baseImage: String! = ""
"""
Marker filename that stops generation for the tree it sits in.
Found with findUp from the scope, so one file disables generation for
everything beneath it — which is the point, and what no per-scope config
expresses. A scope can stay recorded while `dagger generate` writes nothing into it.
A setting, so a caller that has to generate a marked tree anyway can say so:
empty means no marker, and the e2e checks use that to exercise generation on
the very fixtures the marker keeps `dagger generate` out of.
"""
skipGenerateFilename: String! = ".typescript-ignore-generate"
"""
Files that mark a TypeScript project root, and so a scope this SDK can manage.
"""
let scopeMarkers: [String!]! = ["package.json", "deno.json", "deno.jsonc", "tsconfig.json"]
"""
Engine release the committed library bundle is built for.
This SDK ships the library rather than letting the engine build it, so the
pairing is ours to state: checks drive their harness at this release, and
every fixture module in the repo targets it. Bumping the bundle means bumping
this, and the fixture check fails until they agree.
"""
targetEngineVersion: String! = "1.0.0-beta.11"
"""
Directory holding the generated Dang entrypoint, relative to the module root.
Outside sdk/ on purpose: that tree is mounted as the @dagger.io/dagger package,
and a Dang program has no business inside a node package. The manifest names it
with a leading "./" so the engine classifies it as a local path outright rather
than falling through to a directory probe.
"""
let dangEntrypointDir: String! = "entrypoint"
"""
The dispatcher the engine's builtin TypeScript runtime loads.
Only written for a module that still declares a `[runtime]`: under an
entrypoint nothing reads it, and a stale copy left beside the real dispatcher
is an invitation to edit the wrong file.
"""
let legacyEntrypointFile: String! = "__dagger.entrypoint.ts"
"""
The dispatcher a generated entrypoint shells out to: one JSON request on
stdin, one JSON result on stdout.
The mirror of legacyEntrypointFile, and only written for a module that
declares an `[entrypoint]`: nothing else loads it, and a copy left behind in a
`[runtime]` module sits beside __dagger.entrypoint.ts under a name close
enough to invite editing the wrong one.
"""
let dispatchFile: String! = "__dagger.dispatch.ts"
"""
Starter used by init when none is named.
"""
let defaultTemplate: String! = "default"
"""
Find the TypeScript project containing the workspace cwd.
The engine calls this to decide which scope a `dagger module client add`
belongs to, and which SDK owns it: every installed SDK is asked, and the
deepest answer wins. Answering null means this SDK manages nothing here, so a
directory with no TypeScript project is left to another SDK rather than
claimed.
The result is workspace-root-relative and must contain the cwd; the engine
rejects anything else.
"""
findClientRoot(ws: Workspace!): String {
# Deepest marker wins, regardless of which filename found it: a package.json
# beside the cwd describes the cwd's project, an ancestor's describes the
# monorepo above it.
let found = scopeMarkers.reduce(null) { acc, marker =>
let hit = ws.findUp(marker)
if (configHitDepth(hit) > configHitDepth(acc)) { hit } else { acc }
}
if (found == null) { null } else { dirOf(found) }
}
"""
Generate every file this SDK owns in one scope.
The engine sets the workspace cwd to the scope, hands over the scope's
complete desired state, and diffs whatever workspace comes back — so this both
creates a module that does not exist yet and refreshes one that does. What
tells them apart is a module config: a scope with neither
`dagger-module.toml` nor `dagger.json` gets the starter laid down first.
`name` comes from the workspace config, not from the module on disk. It is the
scope's identity, so a module is named by what the user asked for even before
any file exists to read a name out of.
"""
generateScope(
ws: Workspace!,
"""
Whether this scope holds a module, as opposed to only generated clients.
"""
isModule: Boolean!,
"""
The scope's recorded name. Used as the module's name when isModule.
"""
name: String!,
"""
Every module this scope generates a client for. The complete desired set:
a target that has been removed is simply absent, and its files go with it.
"""
clients: [ModuleSource!]!,
): Workspace! {
let scope = scopePath(ws)
if (hasSkipMarker(ws)) {
# Nothing to write, and nothing to say: an unchanged workspace is an empty
# changeset, which is what a generated-file check needs to stay green.
ws
} else if (isModule) {
# One path serves init, migration and every later generation, and which
# config the scope arrived with is what separates them: a manifest means
# generate it as it stands, only a dagger.json means migrate it first,
# neither means lay down the starter.
let hasToml = hasScopeFile(ws, scope, "dagger-module.toml")
let hasJSON = hasScopeFile(ws, scope, "dagger.json")
# Everything below addresses the workspace from its root, and the engine
# requires that of one thing in particular: it resolves a module's local
# [[dependencies]] to workspace-root-relative paths and then reads them
# relative to Workspace.cwd (ResolveDepToSource), so at the scope's own
# cwd a dependency is looked for underneath the module that declares it.
let rooted = ws.withWorkdir(".")
# Seeding detects from the scope because there is no manifest yet to
# resolve a `source` out of — and a scope with no module is its own source
# directory anyway.
let seeded = if (hasToml or hasJSON) {
rooted
} else {
seedModule(rooted, scope, name, effectiveRuntime(rooted, scope))
}
# The entrypoint manifest is written on every run rather than only when the
# scope has none: rewriting one that exists is what lets an existing module
# be flipped onto an entrypoint by turning the setting on.
let configured = if (entrypoint) {
withEntrypointManifest(seeded, scope, name, hasToml)
} else if (hasToml) {
seeded
} else {
withModuleManifest(seeded, scope, name, hasJSON)
}
# A module's files follow its manifest's `source`, which migration can aim
# away from the directory holding the manifest. The config the runtime is
# detected from moves with them, so `source` has to be resolved before the
# runtime is: a split-layout Deno module keeps its deno.json beside its
# code, and detecting at the manifest's directory would regenerate it as
# Node and leave it unable to resolve @dagger.io/dagger at all.
# An entrypoint manifest has no `source` field to aim anywhere else — it
# goes with the `[runtime]` it hangs off — so a module's files are simply
# its scope. Asking the engine gives the wrong answer
# rather than no answer: sourceSubpath comes back unset, moduleSourcePath
# reads that as ".", and generation then looks for src/ at the workspace
# root instead of inside the module.
let source = if (entrypoint) { scope } else { moduleSourcePath(configured, scope) }
let rt = effectiveRuntime(configured, source)
# The same targets feed the module's runtime dependencies and its clients/
# directory — the per-module client files its own source imports and any
# caller outside it installs.
let targets = clientTargets(ws, clients)
# Before the schema is read: a target that is not a dependency yet is not
# in the schema the bindings are generated from.
#
# Skipped entirely under an entrypoint, where there is no [runtime] for a
# dependency to belong to: the builder rejects one on its own
# ("legacy runtime is required when legacy fields are set"), so upserting
# would not add a dependency, it would fail the generation. The
# consequence is real and has no workaround here: a module with an
# entrypoint cannot serve a client yet.
let served = if (entrypoint) {
configured
} else {
withClientDependencies(configured, scope, clients)
}
# The module's own path among the recorded clients is `dagger module
# client add .`: the opt-in for the self client. It renders through the
# same per-module packaging as any target, so it leaves the target set
# here and comes back as the second pass's self.
let selfRecorded = targets.filter { t => t.isLocal and t.path == scope }.length > 0
let externalTargets = targets.filter { t => (t.isLocal and t.path == scope) == false }
# First pass, staged on a workspace nothing reads back. Its only job is to
# make the module loadable, which is what the self target needs; its
# bindings are not the ones that belong on disk, so `served` — not this —
# is what the result is written onto.
let firstPass = moduleGenerated(served, scope, source, name, rt, externalTargets)
let staged = stagedModule(served, source, firstPass)
# Under an entrypoint the self client exists only when asked for. Since
# the ask is explicit and the SDK owns the module's config, the dep is
# wired too — the user runs `npm install` and calls themselves. The
# embedded `[runtime]` layout keeps its always-generated self client, a
# loose clients/ file reached by tsconfig alias — that path predates the
# opt-in and stays as it was. A module that cannot load yet contributes
# nothing; its next generation repairs it.
let selfTargets = if (entrypoint == false or selfRecorded) { selfClientTarget(staged, scope, name) } else { [] }
let withSelf = externalTargets + selfTargets
let complete = moduleFiles(
served,
source,
withSelfBindings(staged, scope, name, firstPass, withSelf, selfTargets, rt, existingModuleConfig(served, source)),
# An embedded module's second pass runs whenever the staged module
# loads, so an empty self set there means it did not — and a run that
# could not read the module's client set has no business pruning
# bindings it could not re-render. The entrypoint path loads through the
# Dang literals and is never in that position.
entrypoint or selfTargets.length > 0,
)
# The workspace-level .dagger/core an earlier layout shared is gone: every
# scope carries its own clients/dagger now, so the old copy is pruned by
# whichever scope generates first.
let migrated = withoutLegacyWorkspaceCore(complete, configDir(ws))
# Back to the cwd the engine set: it rejects a generateScope that returns
# a workspace standing somewhere else.
migrated.withWorkdir(scope)
} else {
# A client-only scope: clients/<module> packages under the scope, with the
# scope's own clients/dagger beside them, wired through its package.json.
withoutLegacyWorkspaceCore(clientScope(ws, scope, clientTargets(ws, clients)), configDir(ws))
}
}
"""
Whether the generate skip marker sits at or above the scope.
"""
let hasSkipMarker(ws: Workspace!): Boolean! {
if (skipGenerateFilename == "") {
false
} else {
ws.findUp(skipGenerateFilename) != null
}
}
"""
The scope the engine is asking about, as a workspace-root-relative path.
Every helper here addresses the workspace from its root, so the cwd the engine
set is read once and converted, rather than threading two path conventions
through generation.
"""
let scopePath(ws: Workspace!): String! {
let cwd = ws.cwd.trimPrefix("/").trimSuffix("/")
if (cwd == "") { "." } else { cwd }
}
"""
Directory holding a workspace-absolute file path, "." at the root.
"""
let dirOf(path: String!): String! {
let dir = path.trimPrefix("/").split("/").dropLast(1).join("/")
if (dir == "") { "." } else { dir }
}
"""
Join a scope-relative filename onto a workspace-root-relative scope path.
"""
let scopeFile(scope: String!, name: String!): String! {
if (scope == ".") { name } else { scope + "/" + name }
}
"""
Directory holding the workspace's dagger.toml, workspace-root-relative — "."
when there is none. Everything this SDK shares across scopes anchors here,
where the engine keeps .dagger/modules; found from the cwd because the config
may sit below the workspace root, where a search from the root never looks.
"""
let configDir(ws: Workspace!): String! {
let found = ws.findUp("dagger.toml")
if (found == null) { "." } else { dirOf(found) }
}
"""
The @dagger.io/dagger package a scope's clients/dagger holds: the shipped
bundle plus the scope's own core bindings, completed into a real npm package.
Every scope carries its own copy — one resolution context, one core — and
every other package in the scope's clients/ depends on it by file:../dagger.
It stands in for a published @dagger.io/dagger; the swap later is a specifier
change, nothing regenerates.
"""
let corePackage(bindings: Directory!): Directory! {
moduleSdkDirectory(bindings).withNewFile("package.json", corePackageJSON)
}
"""
The core package's manifest: @dagger.io/dagger, entry at the hand-written
index.ts (which re-exports the runtime and the generated core bindings),
telemetry as its one sub-path. Spelled three ways because a resolver reads one
of them — `exports` is what node honours, `main`/`types` are what a
classic-resolution tsconfig and a publish need.
"""
let corePackageJSON: String! {
"{\n" +
" \"name\": \"@dagger.io/dagger\",\n" +
" \"version\": \"0.0.0\",\n" +
" \"type\": \"module\",\n" +
" \"main\": \"./index.ts\",\n" +
" \"types\": \"./index.ts\",\n" +
" \"exports\": {\n" +
" \".\": \"./index.ts\",\n" +
" \"./telemetry\": \"./telemetry.ts\"\n" +
" }\n" +
"}\n"
}
"""
Take the workspace-level .dagger/core an earlier layout shared across scopes:
every scope carries its own clients/dagger now. A no-op once it is gone.
"""
let withoutLegacyWorkspaceCore(ws: Workspace!, cfg: String!): Workspace! {
let old = scopeFile(cfg, ".dagger/core")
if (existingDir(ws, old).entries.length == 0) {
ws
} else {
ws.withoutDirectory("/" + old)
}
}
"""
Depth of the directory holding a find-up config hit (a workspace-absolute path).
A deeper hit is nearer the search origin; a null (no hit) ranks below any hit,
so ranking by this picks the nearest enclosing config regardless of which
filename found it.
"""
let configHitDepth(hit: String): Int! {
if (hit == null) {
-1
} else {
let dir = hit.split("/").dropLast(1).join("/").trimPrefix("/")
if (dir == "") { 0 } else { dir.split("/").length }
}
}
"""
Whether the module config at `configPath` (workspace-root-relative) declares the
TypeScript runtime — an `"sdk"` naming typescript in a dagger.json, or a
`[runtime]` table with `source = "typescript"` in a dagger-module.toml. The
filename picks which pattern to match.
"""
let isTypescriptConfig(ws: Workspace!, configPath: String!): Boolean! {
let pattern = if (configPath.trimSuffix("dagger-module.toml") != configPath) { tsTomlPattern } else { tsPattern }
ws
.directory("/", include: [configPath])
.file(configPath)
.search(
pattern: pattern,
multiline: true,
dotall: true,
limit: 1,
)
.{{id}}
.length > 0
}
"""
Whether the JSON config at `configPath` (workspace-root-relative) mentions the
named field.
Matched as a key — `"name":` — rather than as a bare substring, so a
dependency named "blueprint" does not read as a blueprint field.
"""
let configDeclaresKey(ws: Workspace!, configPath: String!, key: String!): Boolean! {
ws
.directory("/", include: [configPath])
.file(configPath)
.search(
pattern: "\"" + key + "\"\\s*:",
multiline: true,
dotall: true,
limit: 1,
)
.{{id}}
.length > 0
}
"""
Whether a scope holds the named file.
"""
let hasScopeFile(ws: Workspace!, scope: String!, name: String!): Boolean! {
let path = scopeFile(scope, name)
ws.directory("/", include: [path]).exists(path)
}
"""
Give a scope the `dagger-module.toml` it does not have yet: a fresh manifest
for a scope with no module, or a pre-1.0 `dagger.json` loaded, re-emitted as
TOML and then removed.
Migrating is the whole reason this is not folded back into seeding. Leaving
both files behind would leave the module with two manifests free to disagree,
and the manifest schema round-trips through the builder, so what the
`dagger.json` carried — `source`, `include`, `[[dependencies]]`, its engine
pin — comes out the other side. Only a scope the workspace records is ever
generated, so migration reaches a module because someone asked for it.
A scope that already has a manifest does not come through here at all;
reconciling its clients into it is `withClientDependencies`, which runs for
every module scope rather than only the ones being created.
"""
let withModuleManifest(ws: Workspace!, scope: String!, name: String!, hasJSON: Boolean!): Workspace! {
let configPath = scopeFile(scope, "dagger.json")
let jsonPath = "/" + configPath
let existing = if (hasJSON) {
# Migration deletes the dagger.json, so anything the manifest cannot carry
# is gone for good. The builder validates `toolchains` and `blueprint` on
# load and then emits neither — dagger-module.toml has no table for them —
# so migrating such a module would silently drop what it declared. Refuse
# instead: raising writes nothing, and the dagger.json stays where it is.
if (configDeclaresKey(ws, configPath, "toolchains")) {
raise "cannot migrate " + configPath + " to dagger-module.toml: \"toolchains\" has no equivalent there"
} else if (configDeclaresKey(ws, configPath, "blueprint")) {
raise "cannot migrate " + configPath + " to dagger-module.toml: \"blueprint\" has no equivalent there"
} else if (isTypescriptConfig(ws, configPath) == false) {
# The builder copies whatever `sdk` says straight into `[runtime]`, so a
# scope mistakenly recorded as TypeScript over a Go or Python module
# would have its config rewritten, deleted, and then run through
# TypeScript codegen.
raise "cannot migrate " + configPath + ": it does not use the TypeScript SDK"
}
sdkHelpers.moduleManifest(loadJson: ws.file(jsonPath))
} else {
# Pin the engine version to the release this SDK's committed bundle was
# built for, rather than letting it default to whichever engine happens to
# be running: the generated files come from the bundle, so that is the
# pairing the module actually has.
sdkHelpers.moduleManifest.withLegacyTypescriptRuntime(engineVersion: "v" + targetEngineVersion)
}
existing.withName(name: name)
.generate(ws.withWorkdir(scope), lock: lock, legacyJson: false)
.withWorkdir(".")
}
"""
Write the manifest that selects the generated module entrypoint.
Through the sdk-helpers builder, like every other manifest this SDK writes.
The builder emits no `manifestVersion`, because the schema it implements has
no such field: an `[entrypoint]` table is simply one more table a manifest may
carry, and an engine that does not understand it reads the rest.
The legacy fields go rather than sitting beside it, which that schema would
allow. An entrypoint module's tree has no __dagger.entrypoint.ts, so a
`[runtime]` left in the file would only point an older engine at a file that
is not there — better to be unloadable than half-loadable.
Rewriting rather than merging is what makes flipping an existing module onto
an entrypoint work at all. It is a one-way trip: `engineVersion`, `source` and
`[[dependencies]]` cannot outlive the `[runtime]` they hang off.
"""
let withEntrypointManifest(ws: Workspace!, scope: String!, name: String!, hasToml: Boolean!): Workspace! {
let existing = if (hasToml) {
sdkHelpers.moduleManifest(loadToml: ws.file("/" + scopeFile(scope, "dagger-module.toml")))
} else {
sdkHelpers.moduleManifest
}
existing.withName(name: name)
.withoutLegacyFields
.withDangEntrypoint(source: "./" + dangEntrypointDir)
.generate(ws.withWorkdir(scope), lock: lock, legacyJson: false)
.withWorkdir(".")
}
"""
Record the scope's client targets as the module's runtime dependencies.
This is what makes a client callable rather than merely typed. A module's
session serves what its manifest lists; a scope's clients live in dagger.toml,
which only the engine reads. Without this the bindings generate, type-check,
and then fail at run time with `Cannot query field "<target>" on type "Query"`.
It also has to happen before the module's schema is read, because the schema is
what the bindings are generated from: a target that is not a dependency yet is
not in it.
Dependencies are upserted, never pruned. A manifest may carry entries nobody
here put there — written by hand, or migrated from a pre-1.0 dagger.json — and
they are indistinguishable from ours, so removing the ones that are missing
from `clients` would take those with them. The cost is that dropping a client
leaves its dependency behind, serving a module whose bindings are gone.
Generation also applies the lock setting when there are no clients.
The manifest is re-emitted every run and comes out the same nearly every time,
so the result is kept only when it differs. Everything the builder records
lands in that one file, which makes comparing it enough to say the run changed
nothing — and a workspace that wrote a file is a workspace that reports it,
identical bytes or not.
"""
let withClientDependencies(ws: Workspace!, scope: String!, clients: [ModuleSource!]!): Workspace! {
let tomlPath = "/" + scopeFile(scope, "dagger-module.toml")
let updated = clients.reduce(sdkHelpers.moduleManifest(loadToml: ws.file(tomlPath))) { manifest, client =>
manifest.withLegacyRuntimeDependency(module: client)
}.generate(ws.withWorkdir(scope), lock: lock, legacyJson: false).withWorkdir(".")
if (updated.file(tomlPath).digest(excludeMetadata: true) == ws.file(tomlPath).digest(excludeMetadata: true)) {
ws
} else {
updated
}
}
"""
The runtime a scope generates for: the `runtime` setting when set, otherwise
whatever the files already there imply.
Detection is the default so that adopting this SDK, or regenerating a module
created before the setting existed, does not silently move a Bun or Deno
project onto Node.
"""
let effectiveRuntime(ws: Workspace!, scope: String!): Runtime! {
if (runtime == "") {
ModConfig(sourcePath: scope, ws: ws).runtime
} else if (runtime == "node" or runtime == "NODE") {
Runtime.NODE
} else if (runtime == "bun" or runtime == "BUN") {
Runtime.BUN
} else if (runtime == "deno" or runtime == "DENO") {
Runtime.DENO
} else {
raise "unknown runtime: \"" + runtime + "\"; expected node, bun or deno"
}
}
"""
The package manager a scope's dependencies are installed with, as
`name@version`.
Only an entrypoint module needs this. Under a `[runtime]` the engine detects
it and runs the install itself; under an entrypoint the generated recipe is
ours, so the answer is baked into it at generation like every other value the
container is built from.
The `packageManager` setting wins where it is set, because a caller passing it
to an existing module never reaches the package.json seeding that would
otherwise record it.
"""
let effectivePackageManager(ws: Workspace!, scope: String!, rt: Runtime!): String! {
if (packageManager != "" and rt == Runtime.NODE) {
packageManager
} else {
ModConfig(sourcePath: scope, ws: ws).packageManagerFor(rt)
}
}
"""
Lay down the files a brand-new module needs: the rendered starter and the
config files its runtime is detected from. The manifest that makes it a
module is withModuleManifest's, written right after this.
Merge, never replace. A scope is a directory the user may already work in — an
existing package.json / tsconfig.json / deno.json keeps its scripts, path
aliases and unstable flags, and everything else in the directory is left alone.
"""
let seedModule(ws: Workspace!, scope: String!, name: String!, rt: Runtime!): Workspace! {
# An empty setting means "the default", not the templates/ directory itself —
# which exists, so it would pass the check below and render every starter as
# a subdirectory of the new module.
let starter = if (template == "") { defaultTemplate } else { template }
if (currentModule.source.exists("templates/" + starter) == false) {
raise "unknown init template: " + starter
} else if (packageManager != "" and rt != Runtime.NODE) {
raise "packageManager is only supported with the node runtime; bun and deno bundle their own"
} else {
let existing = ws.directory("/", include: [
scopeFile(scope, "package.json"),
scopeFile(scope, "tsconfig.json"),
scopeFile(scope, "deno.json"),
scopeFile(scope, "bun.lock"),
])
let rendered = renderedTemplate(name, starter, rt, existing, scope)
let configured = configuredTemplate(rendered, rt, packageManager, baseImage)
ws.withDirectory("/" + scope, configured)
}
}
"""
Existing contents of a workspace directory, empty when it does not exist yet.
"""
let existingDir(ws: Workspace!, path: String!): Directory! {
if (path == ".") {
ws.directory("/")
} else {
let filtered = ws.directory("/", include: [path + "/**"])
if (filtered.exists(path)) {
filtered.directory(path)
} else {
directory
}
}
}
"""
Return init templates tracked by this module.
Templates live under templates/<name> and are materialized into the new
module. Init picks one by name, or the module default when none is named.
"""
templates: [Template!]! {
if (currentModule.source.exists("templates")) {
let root = currentModule.source.directory("templates")
root.entries.map { name =>
Template(
name: name.trimSuffix("/"),
source: root.directory(name),
)
}
} else {
directory.entries.map { name =>
Template(
name: name,
source: directory,
)
}
}
}
"""
Render the templates/<template> starter with the requested module name and runtime.
Seeds index.ts from the template, then layers in runtime-specific config
files via the config-updater helper. The config-updater reads existing user
config files (rooted at modPath in `existing`) so any user customizations are
preserved; only Dagger-required keys are added or refreshed.
"""
let renderedTemplate(name: String!, template: String!, runtime: Runtime!, existing: Directory!, modPath: String!): Directory! {
let existingPrefix = if (modPath == ".") { "/existing/" } else { "/existing/" + modPath + "/" }
let wsPrefix = if (modPath == ".") { "" } else { modPath + "/" }
# Two helpers in one container, layered onto the shared config-updater build
# so it is computed once for both this and configUpdaterBuilder.
let builder = configUpdaterBuilder
.withDirectory("/render-template", currentModule.source.directory("helpers/render-template"))
.withWorkdir("/render-template")
.withExec(["go", "build", "-o", "/usr/local/bin/render-template", "."])
.withDirectory("/template", currentModule.source.directory("templates/" + template))
.withDirectory("/existing", existing)
.withExec(["render-template", name, "/template", "/rendered"])
let withConfig = if (runtime == Runtime.NODE) {
builder
.withExec(["config-updater", "package-json", existingPrefix + "package.json", "/rendered/package.json"])
.withExec(["config-updater", "tsconfig", existingPrefix + "tsconfig.json", "/rendered/tsconfig.json", "", "clients"])
} else if (runtime == Runtime.BUN) {
# The Dagger TypeScript runtime picks bun over node by spotting bun.lock,
# so we emit an empty one for a fresh init. If the workspace already has
# one we leave it alone: init layers the template onto the existing
# directory, so writing it here would truncate the user's lockfile.
let nodeConfig = builder
.withExec(["config-updater", "package-json", existingPrefix + "package.json", "/rendered/package.json"])
.withExec(["config-updater", "tsconfig", existingPrefix + "tsconfig.json", "/rendered/tsconfig.json", "", "clients"])
if (existing.exists(wsPrefix + "bun.lock")) {
nodeConfig
} else {
nodeConfig.withExec(["touch", "/rendered/bun.lock"])
}
} else {
builder
.withExec(["config-updater", "deno-config", existingPrefix + "deno.json", "/rendered/deno.json", "", "clients"])
}
withConfig.directory("/rendered")
}
"""
Apply non-default `packageManager` / `baseImage` flags to a rendered template.
When both flags are empty the source is returned unchanged (no helper run,
no reformatting). Otherwise the module-config helper edits the rendered
config files in place: `packageManager` always writes to package.json,
`baseImage` writes to deno.json for the DENO runtime and to package.json
otherwise — matching where ModConfig later reads from.
"""
let configuredTemplate(source: Directory!, runtime: Runtime!, packageManager: String!, baseImage: String!): Directory! {
if (packageManager == "" and baseImage == "") {
source
} else {
let baseImageFileName = if (runtime == Runtime.DENO) { "deno.json" } else { "package.json" }
# Only edit config files the template actually ships. The module-config
# helper treats a missing file as "{}" and would otherwise materialize a
# brand-new package.json/deno.json that the template intentionally omitted.
if (packageManager != "" and source.exists("package.json") == false) {
raise "cannot configure --package-manager: template has no package.json"
} else if (baseImage != "" and source.exists(baseImageFileName) == false) {
raise "cannot configure --base-image: template has no " + baseImageFileName
} else {
let built = moduleConfigBuilder.withDirectory("/rendered", source)
let withPm = if (packageManager == "") {
built
} else {
built.withExec(["module-config", "set-package-manager", "/rendered/package.json", packageManager])
}
let withImg = if (baseImage == "") {
withPm
} else {
withPm.withExec(["module-config", "set-base-image", "/rendered/" + baseImageFileName, baseImage])
}
withImg.directory("/rendered")
}
}
}
"""
Container with the Go helper at helpers/<name> compiled and on PATH at
/usr/local/bin/<name>.
The helpers differ only in which directory they build, so they share one
recipe: a copy per helper is a copy per helper of the toolchain version, the
cache mounts and the output path, each free to drift from the others.
"""
let goHelper(name: String!): Container! {
container
.from("golang:1.25-alpine")
.withoutEntrypoint
.withMountedCache("/go/pkg/mod", cacheVolume("go-mod"))
.withMountedCache("/root/.cache/go-build", cacheVolume("go-build"))
.withDirectory("/helper", currentModule.source.directory("helpers/" + name))
.withWorkdir("/helper")
.withExec(["go", "build", "-o", "/usr/local/bin/" + name, "."])
}
"""
Container with the module-config helper compiled and on PATH.
Shared by init's template configuration (configuredTemplate) and ModConfig's
package-manager reader (ModConfig.tool).
"""
let moduleConfigBuilder: Container! {
goHelper("module-config")
}
"""
Container with the TypeScript bindings generator compiled and on PATH.
Engine-free: it turns an introspection schema into generated bindings, either
for a module (`codegen module`) or for a standalone client bound to one
(`codegen client`).
"""
let codegenBuilder: Container! {
goHelper("codegen")
}
"""
Container with the config-updater helper compiled and on PATH.
"""
let configUpdaterBuilder: Container! {
goHelper("config-updater")
}
"""
The prebuilt TypeScript library this SDK ships: core.js and its declarations,
the module-facing index.ts/telemetry.ts wrappers, the module introspector, and
the compiler version the introspector must be run with.
Built from the vendored library sources by .dagger/modules/packager and
committed, so generating a module needs no bun toolchain and no network.
"""
let tsBundle: Directory! {
currentModule.source.directory("library/bundle")
}
"""
A file from the prebuilt library this SDK ships, so callers can tell what a
generated module was built against.
"""
libraryBundleFile(name: String!): File! {
tsBundle.file(name)
}
"""
Generate a module's own bindings: client.gen.ts holding core types, one
<module>.gen.ts client per manifest dependency and per recorded target, and
the loader.gen.ts the entrypoints load ID-carrying values through.
Each module's file is a self-contained client — its own root Client, its own
dag, its own entrypoint functions — reached from the module's source through
@dagger.io/<module>. The standalone package under `clients/` renders
the same shape for code outside the module; what still separates the two is
where they live and how they resolve the runtime, not what they contain.
Engine-free: the schemas arrive as data, so this is a plain container exec.
"""
let generateModuleBindings(schemaJSON: String!, moduleName: String!, targets: [ClientTarget!]!, repoURL: String!): Directory! {
let base = codegenBuilder.withNewFile("/schema.json", schemaJSON)
let withTargets = if (targets.length == 0) { base } else { stageTargets(base, targets) }
let clientMetaArgs = if (targets.length == 0) {
[]
} else {
["--client-meta-path", "/meta.json"]
}
# Under an entrypoint each client nests in its own package directory and the
# loader imports it relatively; the embedded [runtime] layout keeps the flat
# clients/<m>.gen.ts files its tsconfig aliases point at.
let layoutArgs = if (entrypoint) { ["--packaged-clients"] } else { ["--flat-clients"] }
withTargets
.withExec([
"codegen", "module",
"--introspection-json-path", "/schema.json",
"--module-name", moduleName,
"--output", "/out",
"--workspace-repo-url", repoURL,
] + clientMetaArgs + layoutArgs)
.directory("/out")
}
"""
The Git repository the workspace itself was loaded from, or "" for a local one.
Dagger Cloud runs checks against a workspace loaded by address
(`github.com/org/repo@<sha>`), and the engine then loads the workspace's own
modules as Git sources and stamps their source maps with URLs carrying that
commit. Codegen drops those and keeps the module-relative filename, so a
generated file reads the same whichever way the workspace was loaded — which
is what a committed-output check needs.
Read off any module the workspace can load at `path`; every module in a
workspace shares its repository, so one answer serves them all. A path with no
module, or a workspace that is not Git-backed, has nothing to strip.
"""
let workspaceRepoURL(ws: Workspace!, path: String!): String! {
{
let source = ws.moduleSource("/" + path)
if (source.kind == ModuleSourceKind.GIT_SOURCE) { source.htmlRepoURL } else { "" }
} rescue ""
}
"""
The same, for a scope that may not be a module itself.
A client-only scope has no module at its own path, so the repository is read
off the first target that is one. Targets outside the workspace answer "" and
are skipped — and they are the ones whose URLs must survive anyway.
"""
let scopeRepoURL(ws: Workspace!, scope: String!, targets: [ClientTarget!]!): String! {
let own = workspaceRepoURL(ws, scope)
if (own != "") {
own
} else {
let seed: String! = ""
targets.reduce(seed) { found, target =>
if (found != "") { found } else { workspaceRepoURL(ws, target.path) }
}
}
}
"""
Stage every target's schema in the codegen container, plus the meta file that
names them. Shared by both generators: the module one folds the targets into
the module's bindings, the client one renders the package from them.
"""
let targetsMetaJSON(targets: [ClientTarget!]!): String! {
let modulesJSON = targets.map { target =>
"{\"name\":" + JSON.encode(target.name) +
",\"schemaPath\":" + JSON.encode(schemaPath(target.name)) +
",\"kind\":" + target.kindJSON +
",\"path\":" + JSON.encode(target.path) +
",\"ref\":" + JSON.encode(target.ref) +
",\"pin\":" + JSON.encode(target.pin) +
",\"self\":" + (if (target.isSelf) { "true" } else { "false" }) + "}"
}.join(",")
"{\"engineVersion\":" + JSON.encode(targetEngineVersion) + ",\"modules\":[" + modulesJSON + "]}"
}
let stageTargets(builder: Container!, targets: [ClientTarget!]!): Container! {
targets
.reduce(builder) { container, target =>
container.withNewFile(schemaPath(target.name), target.schemaJSON)
}
.withNewFile("/meta.json", targetsMetaJSON(targets))
}
"""
Project the engine-resolved client targets into the pieces both generators
read, in one pass rather than a round-trip per field per module.
"""
let clientTargets(ws: Workspace!, clients: [ModuleSource!]!): [ClientTarget!]! {
clients
.{{ moduleOriginalName, kind, pin, asString, sourceRootSubpath, clientSchemaIntrospectionJSON.{{ contents }} }}
.map { module =>
let local = if (JSON.encode(module.kind) != "\"GIT_SOURCE\"") {
true
} else {
# A Git workspace loads its own modules as Git sources, and one the
# workspace resolves back to the same source belongs to it. Serving
# such a target by ref would pin the generated client to the commit it
# was generated at; by path it renders the same either way.
{
ws.moduleSource("/" + module.sourceRootSubpath).asString == module.asString
} rescue false
}
ClientTarget(
name: module.moduleOriginalName,
schemaJSON: module.clientSchemaIntrospectionJSON.contents,
kindJSON: if (local) { JSON.encode(ModuleSourceKind.LOCAL_SOURCE) } else { JSON.encode(module.kind) },
isLocal: local,
# Local kinds serve by resolving this against the workspace; git
# kinds ignore it and serve from ref+pin instead.
path: module.sourceRootSubpath,
ref: if (local) { "" } else { module.asString },
pin: if (local) { "" } else { module.pin },
isSelf: false,
)
}
}
"""
Assemble a module's sdk/ directory: the shipped bundle plus the core
bindings. Nothing module-specific: sdk/ *is* the @dagger.io/dagger library,
and the per-module clients live in clients/, apart from it, so it can one day