elm-pages 3.0.27 → 3.0.28

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.
@@ -12,7 +12,7 @@
12
12
  "elm/json": "1.1.3",
13
13
  "elm/regex": "1.0.0",
14
14
  "jfmengels/elm-review": "2.15.1",
15
- "mdgriffith/elm-codegen": "5.2.0",
15
+ "mdgriffith/elm-codegen": "6.0.1",
16
16
  "stil4m/elm-syntax": "7.3.8",
17
17
  "the-sett/elm-syntax-dsl": "6.0.3"
18
18
  },
@@ -1,3 +1,3 @@
1
- export const compatibilityKey = 22;
1
+ export const compatibilityKey = 23;
2
2
 
3
- export const packageVersion = "3.0.27";
3
+ export const packageVersion = "3.0.28";
@@ -536,6 +536,8 @@ async function runInternalJob(
536
536
  return [requestHash, await runWhich(requestToPerform)];
537
537
  case "elm-pages-internal://question":
538
538
  return [requestHash, await runQuestion(requestToPerform)];
539
+ case "elm-pages-internal://readKey":
540
+ return [requestHash, await runReadKey(requestToPerform)];
539
541
  case "elm-pages-internal://shell":
540
542
  return [requestHash, await runShell(requestToPerform)];
541
543
  case "elm-pages-internal://stream":
@@ -638,6 +640,10 @@ async function runQuestion(req) {
638
640
  return jsonResponse(req, await question(req.body.args[0]));
639
641
  }
640
642
 
643
+ async function runReadKey(req) {
644
+ return jsonResponse(req, await readKey());
645
+ }
646
+
641
647
  function runStream(req, portsFile) {
642
648
  return new Promise(async (resolve) => {
643
649
  const context = getContext(req);
@@ -667,19 +673,21 @@ function runStream(req, portsFile) {
667
673
  index += 1;
668
674
  }
669
675
  if (kind === "json") {
670
- resolve(
671
- jsonResponse(req, {
672
- body: await consumers.json(lastStream),
673
- metadata: await tryCallingFunction(metadataResponse),
674
- })
675
- );
676
+ try {
677
+ const body = await consumers.json(lastStream);
678
+ const metadata = await tryCallingFunction(metadataResponse);
679
+ resolve(jsonResponse(req, { body, metadata }));
680
+ } catch (error) {
681
+ resolve(jsonResponse(req, { error: error.toString() }));
682
+ }
676
683
  } else if (kind === "text") {
677
- resolve(
678
- jsonResponse(req, {
679
- body: await consumers.text(lastStream),
680
- metadata: await tryCallingFunction(metadataResponse),
681
- })
682
- );
684
+ try {
685
+ const body = await consumers.text(lastStream);
686
+ const metadata = await tryCallingFunction(metadataResponse);
687
+ resolve(jsonResponse(req, { body, metadata }));
688
+ } catch (error) {
689
+ resolve(jsonResponse(req, { error: error.toString() }));
690
+ }
683
691
  } else if (kind === "none") {
684
692
  if (!lastStream) {
685
693
  // ensure all error handling gets a chance to fire before resolving successfully
@@ -687,22 +695,21 @@ function runStream(req, portsFile) {
687
695
  resolve(jsonResponse(req, { body: null }));
688
696
  } else {
689
697
  let resolvedMeta = await tryCallingFunction(metadataResponse);
690
- lastStream.once("finish", async () => {
698
+ // Writable streams emit "finish", Readable streams emit "end"
699
+ // Duplex streams emit both - use a flag to prevent double-resolve
700
+ let resolved = false;
701
+ const onComplete = () => {
702
+ if (resolved) return;
703
+ resolved = true;
691
704
  resolve(
692
705
  jsonResponse(req, {
693
706
  body: null,
694
707
  metadata: resolvedMeta,
695
708
  })
696
709
  );
697
- });
698
- lastStream.once("end", async () => {
699
- resolve(
700
- jsonResponse(req, {
701
- body: null,
702
- metadata: resolvedMeta,
703
- })
704
- );
705
- });
710
+ };
711
+ lastStream.once("finish", onComplete);
712
+ lastStream.once("end", onComplete);
706
713
  }
707
714
  } else if (kind === "command") {
708
715
  // already handled in parts.forEach
@@ -745,19 +752,36 @@ function runStream(req, portsFile) {
745
752
  env,
746
753
  });
747
754
  if (validateStream.isDuplexStream(newLocal.stream)) {
755
+ newLocal.stream.once("error", (error) => {
756
+ newLocal.stream.destroy();
757
+ resolve({ error: `Custom duplex stream '${part.portName}' error: ${error.message}` });
758
+ });
748
759
  pipeIfPossible(lastStream, newLocal.stream);
760
+ if (!lastStream) {
761
+ endStreamIfNoInput(newLocal.stream);
762
+ }
749
763
  return newLocal;
750
764
  } else {
751
765
  throw `Expected '${part.portName}' to be a duplex stream!`;
752
766
  }
753
767
  } else if (part.name === "customRead") {
768
+ const newLocal = await portsFile[part.portName](part.input, {
769
+ cwd,
770
+ quiet,
771
+ env,
772
+ });
773
+ // customRead can return either a stream directly or { stream, metadata }
774
+ const stream = newLocal.stream || newLocal;
775
+ if (!validateStream.isReadableStream(stream)) {
776
+ throw `Expected '${part.portName}' to return a readable stream!`;
777
+ }
778
+ stream.once("error", (error) => {
779
+ stream.destroy();
780
+ resolve({ error: `Custom read stream '${part.portName}' error: ${error.message}` });
781
+ });
754
782
  return {
755
- metadata: null,
756
- stream: await portsFile[part.portName](part.input, {
757
- cwd,
758
- quiet,
759
- env,
760
- }),
783
+ metadata: newLocal.metadata || null,
784
+ stream: stream,
761
785
  };
762
786
  } else if (part.name === "customWrite") {
763
787
  const newLocal = await portsFile[part.portName](part.input, {
@@ -766,25 +790,42 @@ function runStream(req, portsFile) {
766
790
  env,
767
791
  });
768
792
  if (!validateStream.isWritableStream(newLocal.stream)) {
769
- console.error("Expected a writable stream!");
770
- resolve({ error: "Expected a writable stream!" });
771
- } else {
772
- pipeIfPossible(lastStream, newLocal.stream);
793
+ throw `Expected '${part.portName}' to return a writable stream!`;
794
+ }
795
+ newLocal.stream.once("error", (error) => {
796
+ newLocal.stream.destroy();
797
+ resolve({ error: `Custom write stream '${part.portName}' error: ${error.message}` });
798
+ });
799
+ pipeIfPossible(lastStream, newLocal.stream);
800
+ if (!lastStream) {
801
+ endStreamIfNoInput(newLocal.stream);
773
802
  }
774
803
  return newLocal;
775
804
  } else if (part.name === "gzip") {
776
805
  const gzip = zlib.createGzip();
806
+ gzip.once("error", (error) => {
807
+ gzip.destroy();
808
+ resolve({ error: `gzip error: ${error.message}` });
809
+ });
777
810
  if (!lastStream) {
778
- gzip.end();
811
+ endStreamIfNoInput(gzip);
779
812
  }
780
813
  return {
781
814
  metadata: null,
782
815
  stream: pipeIfPossible(lastStream, gzip),
783
816
  };
784
817
  } else if (part.name === "unzip") {
818
+ const unzip = zlib.createUnzip();
819
+ unzip.once("error", (error) => {
820
+ unzip.destroy();
821
+ resolve({ error: `unzip error: ${error.message}` });
822
+ });
823
+ if (!lastStream) {
824
+ endStreamIfNoInput(unzip);
825
+ }
785
826
  return {
786
827
  metadata: null,
787
- stream: pipeIfPossible(lastStream, zlib.createUnzip()),
828
+ stream: pipeIfPossible(lastStream, unzip),
788
829
  };
789
830
  } else if (part.name === "fileWrite") {
790
831
  const destinationPath = path.resolve(part.path);
@@ -801,9 +842,13 @@ function runStream(req, portsFile) {
801
842
  newLocal.removeAllListeners();
802
843
  resolve({ error: error.toString() });
803
844
  });
845
+ pipeIfPossible(lastStream, newLocal);
846
+ if (!lastStream) {
847
+ endStreamIfNoInput(newLocal);
848
+ }
804
849
  return {
805
850
  metadata: null,
806
- stream: pipeIfPossible(lastStream, newLocal),
851
+ stream: newLocal,
807
852
  };
808
853
  } else if (part.name === "httpWrite") {
809
854
  const makeFetchHappen = makeFetchHappenOriginal.defaults({
@@ -865,6 +910,9 @@ function runStream(req, portsFile) {
865
910
  });
866
911
 
867
912
  pipeIfPossible(lastStream, newProcess.stdin);
913
+ if (!lastStream) {
914
+ endStreamIfNoInput(newProcess.stdin);
915
+ }
868
916
  let newStream;
869
917
  if (output === "MergeWithStdout") {
870
918
  newStream = mergeStreams([newProcess.stdout, newProcess.stderr]);
@@ -874,28 +922,40 @@ function runStream(req, portsFile) {
874
922
  newStream = newProcess.stdout;
875
923
  }
876
924
 
925
+ // For the last process, we need to track metadata resolution
926
+ // so we can resolve it even if the process errors
927
+ let resolveMeta = null;
928
+ const metadataPromise = isLastProcess
929
+ ? new Promise((resolve) => {
930
+ resolveMeta = resolve;
931
+ })
932
+ : null;
933
+
877
934
  newProcess.once("error", (error) => {
878
935
  newStream && newStream.end();
879
936
  newProcess.removeAllListeners();
937
+ // Resolve metadata Promise to prevent hanging awaits
938
+ if (resolveMeta) {
939
+ resolveMeta({ exitCode: null, error: error.toString() });
940
+ }
880
941
  resolve({ error: error.toString() });
881
942
  });
943
+
882
944
  if (isLastProcess) {
945
+ newProcess.once("exit", (code) => {
946
+ if (code !== 0 && !allowNon0Status) {
947
+ newStream && newStream.end();
948
+ resolve({
949
+ error: `Command ${command} exited with code ${code}`,
950
+ });
951
+ }
952
+ resolveMeta({
953
+ exitCode: code,
954
+ });
955
+ });
883
956
  return {
884
957
  stream: newStream,
885
- metadata: new Promise((resoveMeta) => {
886
- newProcess.once("exit", (code) => {
887
- if (code !== 0 && !allowNon0Status) {
888
- newStream && newStream.end();
889
- resolve({
890
- error: `Command ${command} exited with code ${code}`,
891
- });
892
- }
893
-
894
- resoveMeta({
895
- exitCode: code,
896
- });
897
- });
898
- }),
958
+ metadata: metadataPromise,
899
959
  };
900
960
  } else {
901
961
  return { metadata: null, stream: newStream };
@@ -930,6 +990,45 @@ function pipeIfPossible(input, destination) {
930
990
  }
931
991
  }
932
992
 
993
+ /**
994
+ * Safely signals EOF to a writable stream when no input will be piped to it.
995
+ *
996
+ * This is necessary because when a writable stream (like a child process's stdin)
997
+ * is created but nothing is piped to it, the receiving end has no way to know
998
+ * that no data is coming. It will wait indefinitely for the pipe to close.
999
+ *
1000
+ * GUI applications like ksdiff/meld are particularly affected - they wait for
1001
+ * stdin to close before proceeding, causing hangs if we don't explicitly end it.
1002
+ *
1003
+ * @param {import('stream').Writable | null | undefined} stream - The writable stream to end
1004
+ */
1005
+ function endStreamIfNoInput(stream) {
1006
+ if (!stream) {
1007
+ return;
1008
+ }
1009
+
1010
+ // Check if stream is still in a state where .end() is valid
1011
+ // - writable: false if the stream has been destroyed or ended
1012
+ // - writableEnded: true if .end() has already been called
1013
+ // - destroyed: true if .destroy() has been called
1014
+ if (!stream.writable || stream.writableEnded || stream.destroyed) {
1015
+ return;
1016
+ }
1017
+
1018
+ // Add a one-time error handler to prevent unhandled error crashes
1019
+ // This can happen if the child process exits before we call .end()
1020
+ stream.once("error", (err) => {
1021
+ // EPIPE: "broken pipe" - the other end closed before we finished
1022
+ // This is expected if the child process exits quickly
1023
+ if (err.code !== "EPIPE") {
1024
+ // Log unexpected errors but don't crash - this is cleanup code
1025
+ console.error("Stream end error:", err.message);
1026
+ }
1027
+ });
1028
+
1029
+ stream.end();
1030
+ }
1031
+
933
1032
  function stdout() {
934
1033
  return new Writable({
935
1034
  write(chunk, encoding, callback) {
@@ -1077,7 +1176,7 @@ export function pipeShells(
1077
1176
  if (previousProcess === null) {
1078
1177
  currentProcess = spawnCallback(command, args, {
1079
1178
  stdio: ["inherit", "pipe", "inherit"],
1080
- timeout: timeout ? undefined : timeout,
1179
+ timeout: timeout,
1081
1180
  cwd: cwd,
1082
1181
  env: env,
1083
1182
  });
@@ -1087,14 +1186,14 @@ export function pipeShells(
1087
1186
  stdio: quiet
1088
1187
  ? ["pipe", "ignore", "ignore"]
1089
1188
  : ["pipe", "inherit", "inherit"],
1090
- timeout: timeout ? undefined : timeout,
1189
+ timeout: timeout,
1091
1190
  cwd: cwd,
1092
1191
  env: env,
1093
1192
  });
1094
1193
  } else {
1095
1194
  currentProcess = spawnCallback(command, args, {
1096
1195
  stdio: ["pipe", "pipe", "pipe"],
1097
- timeout: timeout ? undefined : timeout,
1196
+ timeout: timeout,
1098
1197
  cwd: cwd,
1099
1198
  env: env,
1100
1199
  });
@@ -1153,6 +1252,48 @@ export async function question({ prompt }) {
1153
1252
  });
1154
1253
  }
1155
1254
 
1255
+ /**
1256
+ * Read a single keypress from stdin without requiring Enter.
1257
+ * Uses raw mode to capture individual keypresses.
1258
+ * Falls back to line-buffered input when not in a TTY (e.g., piped input).
1259
+ */
1260
+ export async function readKey() {
1261
+ const stdin = process.stdin;
1262
+
1263
+ if (!stdin.isTTY) {
1264
+ // Fall back to reading a line when not in a TTY (piped input, CI, etc.)
1265
+ // Takes the first character of the input line
1266
+ const rl = readline.createInterface({ input: stdin });
1267
+ return new Promise((resolve) => {
1268
+ rl.once("line", (line) => {
1269
+ rl.close();
1270
+ resolve(line.charAt(0) || "\n");
1271
+ });
1272
+ });
1273
+ }
1274
+
1275
+ // TTY mode - single keypress without Enter
1276
+ return new Promise((resolve) => {
1277
+ const wasRaw = stdin.isRaw;
1278
+
1279
+ stdin.setRawMode(true);
1280
+ stdin.resume();
1281
+ stdin.setEncoding("utf8");
1282
+
1283
+ stdin.once("data", (key) => {
1284
+ stdin.setRawMode(wasRaw);
1285
+ stdin.pause();
1286
+
1287
+ // Handle Ctrl+C to exit gracefully
1288
+ if (key === "\u0003") {
1289
+ process.exit();
1290
+ }
1291
+
1292
+ resolve(key);
1293
+ });
1294
+ });
1295
+ }
1296
+
1156
1297
  async function runWriteFileJob(req) {
1157
1298
  const { cwd } = getContext(req);
1158
1299
  const data = req.body.args[0];
@@ -1223,7 +1364,7 @@ async function runGlobNew(req, patternsToWatch) {
1223
1364
  }
1224
1365
  return {
1225
1366
  fullPath: fullPath.path,
1226
- captures: mm.capture(pattern, fullPath.path),
1367
+ captures: mm.capture(pattern, fullPath.path) || [],
1227
1368
  fileStats: {
1228
1369
  size: stats.size,
1229
1370
  atime: Math.round(stats.atime.getTime()),
@@ -31,7 +31,7 @@
31
31
  "elm-community/result-extra": "2.4.0",
32
32
  "jluckyiv/elm-utc-date-strings": "1.0.0",
33
33
  "justinmimbs/date": "4.1.0",
34
- "mdgriffith/elm-codegen": "5.2.0",
34
+ "mdgriffith/elm-codegen": "6.0.1",
35
35
  "miniBill/elm-codec": "2.2.0",
36
36
  "noahzgordon/elm-color-extra": "1.0.2",
37
37
  "robinheghan/fnv1a": "1.0.0",
@@ -13,7 +13,7 @@
13
13
  "elm/core": "1.0.5",
14
14
  "elm/html": "1.0.0",
15
15
  "elm/json": "1.1.3",
16
- "mdgriffith/elm-codegen": "5.2.0"
16
+ "mdgriffith/elm-codegen": "6.0.1"
17
17
  },
18
18
  "indirect": {
19
19
  "Chadtech/elm-bool-extra": "2.4.2",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "elm-pages",
3
3
  "type": "module",
4
- "version": "3.0.27",
4
+ "version": "3.0.28",
5
5
  "homepage": "https://elm-pages.com",
6
6
  "moduleResolution": "node",
7
7
  "description": "Hybrid Elm framework with full-stack and static routes.",
@@ -884,44 +884,59 @@ readJson decoder ((Stream ( _, metadataDecoder ) _) as stream) =
884
884
  , body = BackendTask.Http.jsonBody (pipelineEncoder stream "json")
885
885
  , expect =
886
886
  BackendTask.Http.expectJson
887
- (Decode.field "metadata" metadataDecoder
888
- |> Decode.andThen
889
- (\result1 ->
890
- let
891
- bodyResult : Decoder (Result Decode.Error value)
892
- bodyResult =
893
- Decode.field "body" Decode.value
894
- |> Decode.map
895
- (\bodyValue ->
896
- Decode.decodeValue decoder bodyValue
897
- )
898
- in
899
- bodyResult
900
- |> Decode.map
901
- (\result ->
902
- case result1 of
903
- Ok metadata ->
904
- case result of
905
- Ok body ->
906
- Ok
907
- { metadata = metadata
908
- , body = body
909
- }
910
-
911
- Err decoderError ->
912
- FatalError.recoverable
913
- { title = "Failed to decode body"
914
- , body = "Failed to decode body"
915
- }
916
- (StreamError (Decode.errorToString decoderError))
917
- |> Err
918
-
919
- Err error ->
920
- error
921
- |> mapRecoverable (Result.toMaybe result)
922
- |> Err
887
+ (Decode.oneOf
888
+ [ Decode.field "error" Decode.string
889
+ |> Decode.andThen
890
+ (\error ->
891
+ Decode.succeed
892
+ (Err
893
+ (FatalError.recoverable
894
+ { title = "Stream Error"
895
+ , body = error
896
+ }
897
+ (StreamError error)
898
+ )
923
899
  )
924
- )
900
+ )
901
+ , Decode.field "metadata" metadataDecoder
902
+ |> Decode.andThen
903
+ (\result1 ->
904
+ let
905
+ bodyResult : Decoder (Result Decode.Error value)
906
+ bodyResult =
907
+ Decode.field "body" Decode.value
908
+ |> Decode.map
909
+ (\bodyValue ->
910
+ Decode.decodeValue decoder bodyValue
911
+ )
912
+ in
913
+ bodyResult
914
+ |> Decode.map
915
+ (\result ->
916
+ case result1 of
917
+ Ok metadata ->
918
+ case result of
919
+ Ok body ->
920
+ Ok
921
+ { metadata = metadata
922
+ , body = body
923
+ }
924
+
925
+ Err decoderError ->
926
+ FatalError.recoverable
927
+ { title = "Failed to decode body"
928
+ , body = "Failed to decode body"
929
+ }
930
+ (StreamError (Decode.errorToString decoderError))
931
+ |> Err
932
+
933
+ Err error ->
934
+ error
935
+ |> mapRecoverable (Result.toMaybe result)
936
+ |> Err
937
+ )
938
+ )
939
+ ]
925
940
  )
926
941
  }
927
942
  |> BackendTask.andThen BackendTask.fromResult
@@ -3,4 +3,4 @@ module Pages.Internal.Platform.CompatibilityKey exposing (currentCompatibilityKe
3
3
 
4
4
  currentCompatibilityKey : Int
5
5
  currentCompatibilityKey =
6
- 22
6
+ 23
@@ -3,7 +3,7 @@ module Pages.Script exposing
3
3
  , withCliOptions, withoutCliOptions
4
4
  , writeFile
5
5
  , command, exec
6
- , log, sleep, doThen, which, expectWhich, question
6
+ , log, sleep, doThen, which, expectWhich, question, readKey, readKeyWithDefault
7
7
  , Error(..)
8
8
  )
9
9
 
@@ -31,7 +31,7 @@ Read more about using the `elm-pages` CLI to run (or bundle) scripts, plus a bri
31
31
 
32
32
  ## Utilities
33
33
 
34
- @docs log, sleep, doThen, which, expectWhich, question
34
+ @docs log, sleep, doThen, which, expectWhich, question, readKey, readKeyWithDefault
35
35
 
36
36
 
37
37
  ## Errors
@@ -313,6 +313,77 @@ question prompt =
313
313
  }
314
314
 
315
315
 
316
+ {-| Read a single keypress from stdin without requiring Enter.
317
+
318
+ This is useful for interactive prompts where you want immediate response
319
+ to a single key, like confirmation dialogs (y/n) or menu navigation.
320
+
321
+ module ConfirmDemo exposing (run)
322
+
323
+ import BackendTask
324
+
325
+ run : Script
326
+ run =
327
+ Script.withoutCliOptions
328
+ (Script.log "Approve this change? [y/n] "
329
+ |> BackendTask.andThen (\_ -> Script.readKey)
330
+ |> BackendTask.andThen
331
+ (\key ->
332
+ if key == "y" then
333
+ Script.log "Approved!"
334
+
335
+ else
336
+ Script.log "Rejected."
337
+ )
338
+ )
339
+
340
+ Note: Returns the raw key character. Control characters like Ctrl+C will
341
+ terminate the process.
342
+
343
+ When not running in an interactive terminal (e.g., piped input or CI),
344
+ falls back to line-buffered input and returns the first character of the line.
345
+ This allows scripts to work both interactively and with piped input like
346
+ `echo "y" | elm-pages run MyScript.elm`.
347
+
348
+ -}
349
+ readKey : BackendTask error String
350
+ readKey =
351
+ BackendTask.Internal.Request.request
352
+ { body = BackendTask.Http.emptyBody
353
+ , expect = BackendTask.Http.expectJson Decode.string
354
+ , name = "readKey"
355
+ }
356
+
357
+
358
+ {-| Like [`readKey`](#readKey), but returns a default value when Enter is pressed.
359
+
360
+ Script.log "Continue? [Y/n] "
361
+ |> BackendTask.andThen (\_ -> Script.readKeyWithDefault "y")
362
+ |> BackendTask.andThen
363
+ (\key ->
364
+ if String.toLower key == "y" then
365
+ continue
366
+
367
+ else
368
+ abort
369
+ )
370
+
371
+ Useful for prompts where pressing Enter should accept a default option.
372
+
373
+ -}
374
+ readKeyWithDefault : String -> BackendTask error String
375
+ readKeyWithDefault default =
376
+ readKey
377
+ |> BackendTask.map
378
+ (\key ->
379
+ if key == "\u{000D}" || key == "\n" then
380
+ default
381
+
382
+ else
383
+ key
384
+ )
385
+
386
+
316
387
  {-| Like [`command`](#command), but prints stderr and stdout to the console as the command runs instead of capturing them.
317
388
 
318
389
  module MyScript exposing (run)