terminal-quest 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/App.js CHANGED
@@ -26,6 +26,10 @@ export function App() {
26
26
  return _jsx(ProgressScreen, { progress: progress, onNavigate: navigateTo });
27
27
  case 'settings':
28
28
  return _jsx(SettingsScreen, { onNavigate: navigateTo, onReset: resetAll });
29
+ default: {
30
+ const _exhaustive = screen;
31
+ return _jsx(TitleScreen, { onNavigate: navigateTo });
32
+ }
29
33
  }
30
34
  };
31
35
  return _jsx(Box, { flexDirection: "column", children: renderScreen() });
@@ -578,7 +578,7 @@ export const story00 = {
578
578
  checks: [{ type: 'file_exists', path: '/home/watashi/photos/travel/memories.txt' }],
579
579
  hints: [
580
580
  { level: 1, text: '空のファイルを作るコマンドがあります。' },
581
- { level: 2, text: 'touch コマンドでファイルを作れます。フォルダ名/ファイル名 のように書くと、フォルダの中にファイルを作れます。/ は「の中の」という意味です。' },
581
+ { level: 2, text: 'touch コマンドでファイルを作れます。フォルダ名/ファイル名 のように書くと、フォルダの中にファイルを作れます。/ は「の中の」という意味です。たとえば travel/memories.txt は「travel フォルダの中の memories.txt」ということです。' },
582
582
  { level: 3, text: '「touch travel/memories.txt」と入力してみましょう。memories は思い出のことです。' },
583
583
  ],
584
584
  feedbacks: [
@@ -633,15 +633,15 @@ export const story00 = {
633
633
  {
634
634
  id: 'mission-00-05',
635
635
  title: 'いらないファイルを片付けよう',
636
- description: '不要な一時ファイルを探し出して、削除しよう。',
637
- goal: 'find でファイルを検索し、rm で不要ファイルを削除できるようになる',
636
+ description: 'もう使わない一時ファイル(.tmp)を探し出して、削除しよう。.tmp はパソコンが一時的に作ったファイルで、もう不要なものです。',
637
+ goal: 'find でファイルを名前で検索し、rm で不要ファイルを削除できるようになる',
638
638
  review: {
639
639
  question: 'ファイルを名前で検索するコマンドはどれですか?',
640
640
  choices: ['grep', 'find', 'ls', 'cat'],
641
641
  correctIndex: 1,
642
642
  explanation: 'find は、ファイルやフォルダを名前やタイプで検索するコマンドです。grep はファイルの中身を検索します。',
643
643
  },
644
- narrative: 'パソコンの中に「.tmp」という拡張子の一時ファイルがたまっている。これは不要なファイルなので、探し出して片付けよう!',
644
+ narrative: 'パソコンの中に名前の最後が「.tmp」のファイルがたまっている。.tmp は「temporary(一時的)」の略で、パソコンが一時的に作ったいらないファイルだよ。ファイル名の「.」のあとの部分(.txt や .tmp など)はファイルの種類を表しているんだ。探し出して片付けよう!',
645
645
  initialCwd: '/home/watashi',
646
646
  initialFS: mission5FS,
647
647
  newCommands: ['rm', 'find'],
@@ -778,7 +778,7 @@ export const story00 = {
778
778
  ],
779
779
  hints: [
780
780
  { level: 1, text: 'ファイルの行数や文字数を数えるコマンドがあります。' },
781
- { level: 2, text: 'Word Count」の略のコマンドで、-l オプションをつけると行数だけ表示できます。' },
781
+ { level: 2, text: 'wc(Word Count の略)コマンドで数を数えられます。コマンドのあとに「-」から始まるオプション(追加の指示)をつけると動きが変わります。-l は「行数(Lines)だけ数えて」という追加の指示です。' },
782
782
  { level: 3, text: '「wc -l report.txt」と入力してみましょう。-l は「行数だけを数える」という意味です。' },
783
783
  ],
784
784
  },
@@ -788,13 +788,13 @@ export const story00 = {
788
788
  {
789
789
  id: 'mission-00-08',
790
790
  title: '連絡先を整理しよう',
791
- description: '連絡先データを並べ替えたり、重複を取り除いたりしてみよう。',
792
- goal: 'sortuniqcut を使ってデータを加工・整理できるようになる',
791
+ description: '連絡先データを名前順に並べ替えたり、同じ人が2回登録されているのを取り除いたり、名前だけを取り出したりしてみよう。',
792
+ goal: 'sort で並べ替え、uniq で重複をまとめ、cut でほしい列だけを取り出せるようになる',
793
793
  review: {
794
794
  question: '重複した行を取り除くために sort と組み合わせて使うコマンドはどれですか?',
795
795
  choices: ['grep', 'cut', 'uniq', 'wc'],
796
796
  correctIndex: 2,
797
- explanation: 'uniq は、隣接する重複行を取り除くコマンドです。sort で並べ替えてから使うのがポイントです。',
797
+ explanation: 'uniq は、となり合った同じ行を1つにまとめるコマンドです。ただし、離れた場所にある同じ行には気づけません。だから先に sort で並べて、同じ行をとなりどうしにしてから uniq を使うのがコツです。',
798
798
  },
799
799
  narrative: '連絡先のファイル(contacts.csv)がぐちゃぐちゃで、同じ人が何回も登録されている。きれいに並べ替えて、重複を取り除こう!',
800
800
  initialCwd: '/home/watashi',
@@ -817,7 +817,7 @@ export const story00 = {
817
817
  checks: [{ type: 'command_executed', command: 'uniq' }],
818
818
  hints: [
819
819
  { level: 1, text: '重複した行を取り除くコマンドがあります。' },
820
- { level: 2, text: 'uniq コマンドで重複行を取り除けます。ただし uniq は「隣り合った行」しかまとめないので、先に sort で並べてから使うのがコツです。' },
820
+ { level: 2, text: 'uniq コマンドで同じ行を1つにまとめられます。ただし、uniq は「上の行と同じかどうか」しか見ないので、離れた場所にある同じ行には気づけません。だから先に sort で並べて、同じ行をとなりどうしにしてから使いましょう。' },
821
821
  { level: 3, text: '「uniq contacts.csv」と入力してみましょう。' },
822
822
  ],
823
823
  },
@@ -829,8 +829,8 @@ export const story00 = {
829
829
  { type: 'output_contains', pattern: '名前' },
830
830
  ],
831
831
  hints: [
832
- { level: 1, text: 'CSVの特定の列だけを切り出すコマンドがあります。' },
833
- { level: 2, text: 'cut コマンドで列を取り出せます。-d, は「カンマで区切る」、-f1 は「1列目を取り出す」という意味です。' },
832
+ { level: 1, text: 'contacts.csv は「,」(カンマ)でデータが区切られています。この中から名前の部分だけを切り出すコマンドがあります。' },
833
+ { level: 2, text: 'cut コマンドで列を取り出せます。-d, は「カンマで区切って」、-f1 は「1番目の列(名前)を取り出して」という追加の指示です。' },
834
834
  { level: 3, text: '「cut -d, -f1 contacts.csv」と入力してみましょう。' },
835
835
  ],
836
836
  },
@@ -840,26 +840,26 @@ export const story00 = {
840
840
  {
841
841
  id: 'mission-00-09',
842
842
  title: '共有ファイルの設定',
843
- description: 'ファイルの権限を設定して、スクリプトを実行できるようにしよう。',
844
- goal: 'chmod で権限を変更し、パイプでコマンドを連携できるようになる',
843
+ description: 'ファイルには「読んでいい」「書き込んでいい」「動かしていい」の3つの許可があります。集計スクリプトに「動かしていい」の許可を追加しよう。',
844
+ goal: 'chmod でファイルの許可を変更し、プログラムを実行できるようになる',
845
845
  review: {
846
846
  question: 'ファイルに実行権限を追加するコマンドはどれですか?',
847
847
  choices: ['chown +x', 'chmod +x', 'cp +x', 'mv +x'],
848
848
  correctIndex: 1,
849
- explanation: 'chmod (Change Mode) はファイルの権限を変更するコマンドです。+x で実行権限を追加します。',
849
+ explanation: 'chmod (Change Mode) はファイルの許可を変更するコマンドです。+x の x は「execute(実行)」の頭文字で、「このファイルをプログラムとして動かしていいよ」という許可を追加します。',
850
850
  },
851
- narrative: 'shared(共有)フォルダにある集計スクリプト(count.sh)を実行したいけど、実行する権限がない。権限を設定して使えるようにしよう!',
851
+ narrative: 'shared(共有)フォルダにある集計スクリプト(count.sh)を動かしたいけど、「動かしていい」の許可がまだ出ていない。パソコンではファイルごとに「読んでいい」「書き込んでいい」「動かしていい」の許可を設定できるんだ。chmod コマンドで「動かしていい」の許可を追加しよう!',
852
852
  initialCwd: '/home/watashi',
853
853
  initialFS: mission9FS,
854
854
  newCommands: ['chmod'],
855
855
  objectives: [
856
856
  {
857
857
  id: 'obj-00-09-01',
858
- description: '集計スクリプトに実行権限をつけよう(chmod コマンド)',
858
+ description: '集計スクリプトに「動かしていい」の許可をつけよう(chmod コマンド)',
859
859
  checks: [{ type: 'command_executed', command: 'chmod' }],
860
860
  hints: [
861
- { level: 1, text: 'ファイルの権限(パーミッション)を変更するコマンドがあります。' },
862
- { level: 2, text: 'Change Mode」の略のコマンドで、+x をつけるとファイルを「実行してもいいよ」という許可を追加できます。プログラムを動かすときに必要です。' },
861
+ { level: 1, text: 'ファイルの許可(できること)を変更するコマンドがあります。' },
862
+ { level: 2, text: 'chmod(Change Mode の略)コマンドで、+x をつけると「このファイルを動かしていいよ」の許可を追加できます。x は「execute(実行)」の頭文字です。プログラム(.sh ファイルなど)を動かすときに必要です。' },
863
863
  { level: 3, text: '「chmod +x shared/count.sh」と入力してみましょう。shared は共有フォルダ、count.sh は集計スクリプトです。' },
864
864
  ],
865
865
  },
@@ -879,15 +879,15 @@ export const story00 = {
879
879
  {
880
880
  id: 'mission-00-10',
881
881
  title: '変更履歴を管理しよう',
882
- description: 'git を使ってレポートの変更履歴を確認してみよう。',
883
- goal: 'git status git log で変更の状態と履歴を確認できるようになる',
882
+ description: 'git(ギット)は「いつ・誰が・何を変えたか」を記録してくれるツールです。レポートの変更履歴を確認してみよう。',
883
+ goal: 'git status で今の状態を確認し、git log でこれまでの変更の記録(履歴)を見られるようになる',
884
884
  review: {
885
885
  question: 'ファイルの変更状態を確認する git コマンドはどれですか?',
886
886
  choices: ['git log', 'git status', 'git diff', 'git add'],
887
887
  correctIndex: 1,
888
- explanation: 'git status は、どのファイルが変更されたか、ステージされているかなどの現在の状態を表示します。',
888
+ explanation: 'git status は、「どのファイルが変更されたか」「まだ記録していない変更があるか」などの今の状態を表示します。',
889
889
  },
890
- narrative: 'reports(レポート)フォルダでは git を使って変更履歴を管理している。どんな変更が行われたか確認してみよう!',
890
+ narrative: 'reports(レポート)フォルダでは git(ギット)を使って変更の記録を管理しているよ。git は「いつ・誰が・何を変えたか」をぜんぶ覚えてくれる便利なツールなんだ。どんな変更が行われたか確認してみよう!',
891
891
  initialCwd: '/home/watashi/reports',
892
892
  initialFS: mission10FS,
893
893
  newCommands: ['git'],
@@ -841,15 +841,15 @@ export const storyK1 = {
841
841
  {
842
842
  id: 'mission-k1-08',
843
843
  title: '仲間リストを整理しよう',
844
- description: 'party.csv(冒険者の名簿)をきれいに整理しよう!',
845
- goal: 'sortuniqcut をつかってデータを整理できるようになる',
844
+ description: 'party.csv(冒険者の名簿)をきれいに整理しよう!このファイルはデータが「,」(コンマ)でくぎられているよ。',
845
+ goal: 'sort でならびかえ、uniq でおなじ行をまとめ、cut でほしい部分だけをとりだせるようになる',
846
846
  review: {
847
847
  question: 'おなじ行をまとめてくれるコマンドはどれかな?',
848
848
  choices: ['sort', 'uniq', 'cut', 'grep'],
849
849
  correctIndex: 1,
850
- explanation: 'uniq はとなりあうおなじ行をまとめるコマンドだよ。sort でならべてから uniq をつかうと、ぜんぶのおなじ行をまとめられるよ。',
850
+ explanation: 'uniq はとなりあうおなじ行を1つにまとめるコマンドだよ。でも、はなれた場所にあるおなじ行は気づけないんだ。だから先に sort でならべて、おなじ行をとなりどうしにしてから uniq をつかうのがコツだよ。',
851
851
  },
852
- narrative: 'castle(古いお城)で party.csv(冒険者の名簿)を見つけた。でも同じ名前が何回も書いてある。きれいに整理して、どんな職業の仲間がいるか調べよう。',
852
+ narrative: 'castle(古いお城)で party.csv(冒険者の名簿)を見つけた。csv は「,」(コンマ)でデータをくぎって書くファイルだよ。でも同じ名前が何回も書いてある!きれいに整理して、どんな職業の仲間がいるか調べよう。',
853
853
  initialCwd: '/world/castle',
854
854
  initialFS: mission8FS,
855
855
  newCommands: ['sort', 'uniq', 'cut'],
@@ -891,7 +891,7 @@ export const storyK1 = {
891
891
  ],
892
892
  hints: [
893
893
  { level: 1, text: 'とくていの部分だけきりだすコマンドがあるよ。' },
894
- { level: 2, text: '「cut」コマンドで「,」でくぎって2ばんめの部分をとりだせるよ。' },
894
+ { level: 2, text: '「cut」コマンドで「,」(コンマ)でくぎって2ばんめの部分をとりだせるよ。-d, は「コンマでくぎる」、-f2 は「2ばんめをとりだす」という意味だよ。' },
895
895
  {
896
896
  level: 3,
897
897
  text: '「cut -d, -f2 party.csv」とにゅうりょくしてね。party は冒険者名簿のことだよ。',
@@ -908,36 +908,36 @@ export const storyK1 = {
908
908
  {
909
909
  id: 'mission-k1-09',
910
910
  title: '封印を解こう',
911
- description: 'ふういんされた seal.sh(呪文)をとけるようにして、魔法をつかおう!',
912
- goal: 'chmod でけんげんをかえ、パイプで grep をつなげられるようになる',
911
+ description: 'seal.sh(封印の呪文)はプログラムだけど、いまは「動かしちゃダメ」になっている。chmod で「動かしてOK」にかえて、魔法の石から「光」をさがそう!',
912
+ goal: 'chmod でファイルを「動かしてもいいよ」にかえ、パイプ(|)で grep をつなげられるようになる',
913
913
  review: {
914
- question: 'ファイルのけんげん(つかえるかどうか)をかえるコマンドはどれかな?',
914
+ question: 'ファイルを「動かしてもいいよ」にかえるコマンドはどれかな?',
915
915
  choices: ['chown', 'chmod', 'mv', 'cp'],
916
916
  correctIndex: 1,
917
- explanation: 'chmod はファイルのけんげん(パーミッション)をかえるコマンドだよ。「change mode」のりゃくだよ。',
917
+ explanation: 'chmod はファイルの「できること」をかえるコマンドだよ。+x をつけると「プログラムとして動かしてOK」になるよ。「change mode」のりゃくだよ。',
918
918
  },
919
- narrative: 'castle(古いお城)のおくで、ふういんされた seal.sh(封印の呪文)を見つけた!ふういんを解いて実行できるようにしよう。そして stone.txt(魔法の石)の中から「光の魔法」をさがしだそう。',
919
+ narrative: 'castle(古いお城)のおくで、seal.sh(封印の呪文)を見つけた!でも今は「動かしちゃダメ」のロックがかかっていて動かせない。chmod コマンドで「動かしてOK」にしてあげよう。それから stone.txt(魔法の石)にはたくさんの魔法が書いてあるよ。cat と grep をパイプ(|)でつなげて「光」の魔法だけをとりだそう!',
920
920
  initialCwd: '/world/castle',
921
921
  initialFS: mission9FS,
922
922
  newCommands: ['chmod'],
923
923
  objectives: [
924
924
  {
925
925
  id: 'obj-k1-09-01',
926
- description: 'seal.sh(封印の呪文)のふういんを解こう',
926
+ description: 'chmod をつかって seal.sh を「動かしてOK」にしよう',
927
927
  checks: [{ type: 'command_executed', command: 'chmod' }],
928
928
  hints: [
929
- { level: 1, text: 'ファイルのけんげん(つかえるかどうか)をかえるコマンドがあるよ。' },
930
- { level: 2, text: '「chmod」コマンドで「+x」をつけると、実行できるようになるよ。' },
931
- { level: 3, text: '「chmod +x seal.sh」とにゅうりょくしてね。seal は封印の呪文のことだよ。' },
929
+ { level: 1, text: 'ファイルを「動かしてもいいよ」にかえるコマンドがあるよ。' },
930
+ { level: 2, text: '「chmod」コマンドで「+x」をつけると、プログラムとして動かせるようになるよ。x は「execute(じっこう)」の頭文字で、+x は「動かしてOK」という意味だよ。' },
931
+ { level: 3, text: '「chmod +x seal.sh」とにゅうりょくしてね。seal は封印の呪文のことだよ。+x は「動かしてOK」のマークだよ。' },
932
932
  ],
933
933
  feedbacks: [
934
- { pattern: 'cat', message: 'cat はファイルの中身を読むコマンドだよ。ふういんを解く(けんげんをかえる)には、べつのコマンドをつかうよ。' },
935
- { pattern: 'rm', message: 'rm はファイルをけすコマンドだよ。ふういんを解くには、けんげんをかえるコマンドをつかおう。' },
934
+ { pattern: 'cat', message: 'cat はファイルの中身を読むコマンドだよ。ファイルを「動かしてOK」にかえるには、べつのコマンドをつかうよ。' },
935
+ { pattern: 'rm', message: 'rm はファイルをけすコマンドだよ。ファイルを「動かしてOK」にかえるには、べつのコマンドをつかおう。' },
936
936
  ],
937
937
  },
938
938
  {
939
939
  id: 'obj-k1-09-02',
940
- description: 'stone.txt(魔法の石)から「光」の魔法をさがそう',
940
+ description: 'grep をつかって stone.txt から「光」の行だけとりだそう',
941
941
  checks: [
942
942
  { type: 'command_executed', command: 'grep' },
943
943
  { type: 'output_contains', pattern: '光の魔法' },
@@ -957,15 +957,15 @@ export const storyK1 = {
957
957
  {
958
958
  id: 'mission-k1-10',
959
959
  title: '冒険の記録をつけよう',
960
- description: 'gitをつかって、冒険のきろくをかくにんしよう!',
961
- goal: 'git status git log で変更のきろくをかくにんできるようになる',
960
+ description: 'git(ギット)は「いつ・なにを変えたか」をきろくするツールだよ。冒険のきろくをかくにんしよう!',
961
+ goal: 'git status でいまのじょうたいを見て、git log でこれまでのきろく(れきし)をかくにんできるようになる',
962
962
  review: {
963
963
  question: 'いままでのきろく(ログ)を見るgitコマンドはどれかな?',
964
964
  choices: ['git status', 'git log', 'git add', 'git diff'],
965
965
  correctIndex: 1,
966
- explanation: 'git log はいままでのへんこうのきろく(コミット)をひょうじするコマンドだよ。git status はいまのじょうたいをたしかめるコマンドだよ。',
966
+ explanation: 'git log はいままでの変更きろくをじゅんばんにひょうじするコマンドだよ。「いつ・だれが・なにをした」がわかるよ。git status はいまの状態をたしかめるコマンドだよ。',
967
967
  },
968
- narrative: 'base(秘密基地)にもどってきた。冒険のきろくがgitでのこしてある。いままでの冒険をふりかえってみよう!',
968
+ narrative: 'base(秘密基地)にもどってきた。git(ギット)というべんりなツールで冒険のきろくがのこしてある。git は「いつ・なにを変えたか」をぜんぶおぼえてくれるツールだよ。いままでの冒険をふりかえってみよう!',
969
969
  initialCwd: '/world/base',
970
970
  initialFS: mission10FS,
971
971
  newCommands: ['git'],
@@ -7,11 +7,11 @@ export declare class CommandHandler {
7
7
  private executePipeline;
8
8
  private executeSingle;
9
9
  private extractRedirect;
10
- private splitOnPipe;
10
+ splitOnPipe(input: string): string[];
11
11
  /**
12
12
  * Tokenize input string, handling single and double quotes.
13
13
  * Quoted strings preserve internal spaces. Quotes are removed from the result.
14
14
  */
15
- private tokenize;
15
+ tokenize(input: string): string[];
16
16
  }
17
17
  //# sourceMappingURL=CommandHandler.d.ts.map
@@ -54,7 +54,14 @@ export class CommandHandler {
54
54
  const finalArgs = stdin != null && stdin !== ''
55
55
  ? [...args, `__stdin__:${stdin}`]
56
56
  : args;
57
- const result = commandFn(this.fs, finalArgs);
57
+ let result;
58
+ try {
59
+ result = commandFn(this.fs, finalArgs);
60
+ }
61
+ catch (e) {
62
+ const msg = e instanceof Error ? e.message : String(e);
63
+ return { output: '', error: `${commandName}: ${msg}` };
64
+ }
58
65
  // Handle redirect
59
66
  if (redirect && !result.error) {
60
67
  try {
@@ -210,6 +210,14 @@ export class VirtualFS {
210
210
  const srcNode = this.getNode(src);
211
211
  if (!srcNode)
212
212
  throw new Error(`No such file or directory: ${src}`);
213
+ const resolvedSrc = this.resolvePath(src);
214
+ const resolvedDest = this.resolvePath(dest);
215
+ if (resolvedDest === resolvedSrc) {
216
+ throw new Error(`'${src}' and '${dest}' are the same file`);
217
+ }
218
+ if (resolvedDest.startsWith(resolvedSrc + '/')) {
219
+ throw new Error(`Cannot move '${src}' to a subdirectory of itself`);
220
+ }
213
221
  const cloned = this.deepClone(srcNode);
214
222
  if (this.exists(dest) && this.isDirectory(dest)) {
215
223
  const srcParts = this.resolvePath(src).split('/').filter(Boolean);
@@ -24,7 +24,8 @@ export function chmod(fs, args) {
24
24
  fs.setPermissions(filePath, newPerms);
25
25
  }
26
26
  catch (e) {
27
- return { output: '', error: `chmod: ${e.message}` };
27
+ const msg = e instanceof Error ? e.message : String(e);
28
+ return { output: '', error: `chmod: ${msg}` };
28
29
  }
29
30
  return { output: '' };
30
31
  }
@@ -57,7 +57,8 @@ export function cut(fs, args) {
57
57
  content = fs.readFile(files[0]);
58
58
  }
59
59
  catch (e) {
60
- return { output: '', error: `cut: ${e.message}` };
60
+ const msg = e instanceof Error ? e.message : String(e);
61
+ return { output: '', error: `cut: ${msg}` };
61
62
  }
62
63
  }
63
64
  else if (stdin !== undefined) {
@@ -1,4 +1,8 @@
1
1
  export function echo(_fs, args) {
2
+ const stdinIdx = args.findIndex(a => a.startsWith('__stdin__:'));
3
+ if (stdinIdx !== -1) {
4
+ args = [...args.slice(0, stdinIdx), ...args.slice(stdinIdx + 1)];
5
+ }
2
6
  return { output: args.join(' ') };
3
7
  }
4
8
  //# sourceMappingURL=echo.js.map
@@ -181,7 +181,13 @@ function gitStash(fs, args) {
181
181
  if (!fs.exists(stashPath)) {
182
182
  return { output: '', error: 'No stash entries found.' };
183
183
  }
184
- const entry = JSON.parse(fs.readFile(stashPath));
184
+ let entry;
185
+ try {
186
+ entry = JSON.parse(fs.readFile(stashPath));
187
+ }
188
+ catch {
189
+ return { output: '', error: 'error: corrupt stash entry' };
190
+ }
185
191
  // Restore state
186
192
  if (entry.status) {
187
193
  fs.writeFile('.git/status', entry.status);
@@ -207,8 +213,13 @@ function gitStash(fs, args) {
207
213
  for (let i = stashCount - 1; i >= 0; i--) {
208
214
  const stashPath = `.git/stash-stack/${i}`;
209
215
  if (fs.exists(stashPath)) {
210
- const entry = JSON.parse(fs.readFile(stashPath));
211
- lines.push(`stash@{${stashCount - 1 - i}}: WIP on ${entry.branch}`);
216
+ try {
217
+ const entry = JSON.parse(fs.readFile(stashPath));
218
+ lines.push(`stash@{${stashCount - 1 - i}}: WIP on ${entry.branch}`);
219
+ }
220
+ catch {
221
+ lines.push(`stash@{${stashCount - 1 - i}}: (corrupt entry)`);
222
+ }
212
223
  }
213
224
  }
214
225
  return { output: lines.join('\n') };
@@ -41,7 +41,8 @@ export function head(fs, args) {
41
41
  content = fs.readFile(files[0]);
42
42
  }
43
43
  catch (e) {
44
- return { output: '', error: `head: ${e.message}` };
44
+ const msg = e instanceof Error ? e.message : String(e);
45
+ return { output: '', error: `head: ${msg}` };
45
46
  }
46
47
  }
47
48
  else if (stdin !== undefined) {
@@ -6,8 +6,6 @@ import { grep } from './grep.js';
6
6
  import { cp } from './cp.js';
7
7
  import { echo } from './echo.js';
8
8
  import { help } from './help.js';
9
- import { hint } from './hint.js';
10
- import { clear } from './clear.js';
11
9
  import { git } from './git.js';
12
10
  import { mkdir } from './mkdir.js';
13
11
  import { mv } from './mv.js';
@@ -31,8 +29,6 @@ export const commandRegistry = {
31
29
  cp,
32
30
  echo,
33
31
  help,
34
- hint,
35
- clear,
36
32
  git,
37
33
  mkdir,
38
34
  mv,
@@ -23,7 +23,8 @@ export function mkdir(fs, args) {
23
23
  fs.mkdir(path, recursive);
24
24
  }
25
25
  catch (e) {
26
- return { output: '', error: `mkdir: ${e.message}` };
26
+ const msg = e instanceof Error ? e.message : String(e);
27
+ return { output: '', error: `mkdir: ${msg}` };
27
28
  }
28
29
  }
29
30
  return { output: '' };
@@ -8,7 +8,8 @@ export function mv(fs, args) {
8
8
  fs.move(source, dest);
9
9
  }
10
10
  catch (e) {
11
- return { output: '', error: `mv: ${e.message}` };
11
+ const msg = e instanceof Error ? e.message : String(e);
12
+ return { output: '', error: `mv: ${msg}` };
12
13
  }
13
14
  return { output: '' };
14
15
  }
@@ -40,7 +40,8 @@ export function rm(fs, args) {
40
40
  }
41
41
  catch (e) {
42
42
  if (!force) {
43
- return { output: '', error: `rm: ${e.message}` };
43
+ const msg = e instanceof Error ? e.message : String(e);
44
+ return { output: '', error: `rm: ${msg}` };
44
45
  }
45
46
  }
46
47
  }
@@ -59,7 +59,8 @@ export function sort(fs, args) {
59
59
  content = fs.readFile(files[0]);
60
60
  }
61
61
  catch (e) {
62
- return { output: '', error: `sort: ${e.message}` };
62
+ const msg = e instanceof Error ? e.message : String(e);
63
+ return { output: '', error: `sort: ${msg}` };
63
64
  }
64
65
  }
65
66
  else if (stdin !== undefined) {
@@ -83,8 +84,8 @@ export function sort(fs, args) {
83
84
  lines.sort((a, b) => {
84
85
  const ka = getKey(a);
85
86
  const kb = getKey(b);
86
- const na = parseFloat(ka) || 0;
87
- const nb = parseFloat(kb) || 0;
87
+ const na = isNaN(parseFloat(ka)) ? 0 : parseFloat(ka);
88
+ const nb = isNaN(parseFloat(kb)) ? 0 : parseFloat(kb);
88
89
  return na - nb;
89
90
  });
90
91
  }
@@ -41,7 +41,8 @@ export function tail(fs, args) {
41
41
  content = fs.readFile(files[0]);
42
42
  }
43
43
  catch (e) {
44
- return { output: '', error: `tail: ${e.message}` };
44
+ const msg = e instanceof Error ? e.message : String(e);
45
+ return { output: '', error: `tail: ${msg}` };
45
46
  }
46
47
  }
47
48
  else if (stdin !== undefined) {
@@ -11,7 +11,8 @@ export function touch(fs, args) {
11
11
  fs.writeFile(filename, '');
12
12
  }
13
13
  catch (e) {
14
- return { output: '', error: `touch: ${e.message}` };
14
+ const msg = e instanceof Error ? e.message : String(e);
15
+ return { output: '', error: `touch: ${msg}` };
15
16
  }
16
17
  return { output: '' };
17
18
  }
@@ -27,7 +27,8 @@ export function uniq(fs, args) {
27
27
  content = fs.readFile(files[0]);
28
28
  }
29
29
  catch (e) {
30
- return { output: '', error: `uniq: ${e.message}` };
30
+ const msg = e instanceof Error ? e.message : String(e);
31
+ return { output: '', error: `uniq: ${msg}` };
31
32
  }
32
33
  }
33
34
  else if (stdin !== undefined) {
@@ -44,7 +44,8 @@ export function wc(fs, args) {
44
44
  content = fs.readFile(filename);
45
45
  }
46
46
  catch (e) {
47
- return { output: '', error: `wc: ${e.message}` };
47
+ const msg = e instanceof Error ? e.message : String(e);
48
+ return { output: '', error: `wc: ${msg}` };
48
49
  }
49
50
  const lines = content === '' ? 0 : (content.match(/\n/g) || []).length;
50
51
  const words = content === '' ? 0 : content.split(/\s+/).filter(Boolean).length;
@@ -47,6 +47,7 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
47
47
  const [currentHint, setCurrentHint] = useState(null);
48
48
  const [hintLevel, setHintLevel] = useState(0);
49
49
  const commandCountRef = useRef(0);
50
+ const missionCompleteTriggeredRef = useRef(false);
50
51
  const [cmdsHintShown, setCmdsHintShown] = useState(new Set());
51
52
  useInput((input, key) => {
52
53
  if (key.escape) {
@@ -95,6 +96,10 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
95
96
  handleHintRequest();
96
97
  return;
97
98
  }
99
+ if (trimmed === 'clear') {
100
+ setOutputLines([]);
101
+ return;
102
+ }
98
103
  if (trimmed === 'objectives' || trimmed === 'obj') {
99
104
  mission.objectives.forEach((obj, i) => {
100
105
  const done = completedObjectives.includes(obj.id);
@@ -141,10 +146,6 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
141
146
  return;
142
147
  }
143
148
  const result = commandHandler.execute(trimmed);
144
- if (result.output === 'CLEAR_SCREEN') {
145
- setOutputLines([]);
146
- return;
147
- }
148
149
  if (result.error) {
149
150
  let errorText = result.error;
150
151
  if (course === 'kids' && errorText.endsWith(': command not found')) {
@@ -178,13 +179,15 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
178
179
  }
179
180
  }
180
181
  }
181
- // Parse all commands in pipe chain for objective checking
182
- const pipeSegments = trimmed.split('|').map(s => s.trim()).filter(s => s);
182
+ // Parse all commands in pipe chain for objective checking (quote-aware)
183
+ const pipeSegments = commandHandler.splitOnPipe(trimmed).map(s => s.trim()).filter(s => s);
183
184
  let allNewlyCompleted = [];
184
185
  for (const segment of pipeSegments) {
185
- const parts = segment.split(/\s+/);
186
- const cmd = parts[0];
187
- const args = parts.slice(1);
186
+ const tokens = commandHandler.tokenize(segment);
187
+ if (tokens.length === 0)
188
+ continue;
189
+ const cmd = tokens[0];
190
+ const args = tokens.slice(1);
188
191
  const newlyCompleted = missionEngine.checkObjectives(cmd, args, result.output);
189
192
  allNewlyCompleted.push(...newlyCompleted);
190
193
  }
@@ -200,7 +203,8 @@ export function TerminalScreen({ storyId, missionIndex, onNavigate, onMissionCom
200
203
  }
201
204
  }
202
205
  setCurrentHint(null);
203
- if (missionEngine.isAllComplete()) {
206
+ if (missionEngine.isAllComplete() && !missionCompleteTriggeredRef.current) {
207
+ missionCompleteTriggeredRef.current = true;
204
208
  const finalCount = commandCountRef.current;
205
209
  setTimeout(() => {
206
210
  onMissionComplete(storyId, mission.id, hintEngine.getTotalHintsUsed(), finalCount);
@@ -1,22 +1,42 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { initialProgress } from './GameState.js';
5
5
  const SAVE_DIR = join(homedir(), '.terminal-quest');
6
6
  const SAVE_FILE = join(SAVE_DIR, 'progress.json');
7
+ let saveWarningShown = false;
8
+ function migrateProgress(parsed) {
9
+ return {
10
+ completedStories: Array.isArray(parsed.completedStories) ? parsed.completedStories : [],
11
+ storyProgress: (parsed.storyProgress && typeof parsed.storyProgress === 'object') ? parsed.storyProgress : {},
12
+ totalCommandsExecuted: typeof parsed.totalCommandsExecuted === 'number' ? parsed.totalCommandsExecuted : 0,
13
+ totalHintsUsed: typeof parsed.totalHintsUsed === 'number' ? parsed.totalHintsUsed : 0,
14
+ achievements: Array.isArray(parsed.achievements) ? parsed.achievements : [],
15
+ };
16
+ }
7
17
  export function loadProgress() {
8
18
  try {
9
19
  if (!existsSync(SAVE_FILE))
10
20
  return { ...initialProgress };
11
21
  const data = readFileSync(SAVE_FILE, 'utf-8');
12
22
  const parsed = JSON.parse(data);
13
- // Migration: add achievements if missing
14
- if (!parsed.achievements) {
15
- parsed.achievements = [];
23
+ if (!parsed || typeof parsed !== 'object') {
24
+ throw new Error('Invalid progress file format');
16
25
  }
17
- return parsed;
26
+ return migrateProgress(parsed);
18
27
  }
19
- catch {
28
+ catch (e) {
29
+ const msg = e instanceof Error ? e.message : String(e);
30
+ process.stderr.write(`[terminal-quest] 進捗ファイルの読み込みに失敗しました: ${msg}\n` +
31
+ `新しい進捗データで開始します。\n`);
32
+ try {
33
+ if (existsSync(SAVE_FILE)) {
34
+ const backupPath = SAVE_FILE + '.backup.' + Date.now();
35
+ copyFileSync(SAVE_FILE, backupPath);
36
+ process.stderr.write(`[terminal-quest] バックアップを保存しました: ${backupPath}\n`);
37
+ }
38
+ }
39
+ catch { /* backup is best-effort */ }
20
40
  return { ...initialProgress };
21
41
  }
22
42
  }
@@ -26,9 +46,15 @@ export function saveProgress(progress) {
26
46
  mkdirSync(SAVE_DIR, { recursive: true });
27
47
  }
28
48
  writeFileSync(SAVE_FILE, JSON.stringify(progress, null, 2), 'utf-8');
49
+ saveWarningShown = false;
29
50
  }
30
- catch {
31
- // 保存失敗は無視(ゲーム継続可能)
51
+ catch (e) {
52
+ if (!saveWarningShown) {
53
+ const msg = e instanceof Error ? e.message : String(e);
54
+ process.stderr.write(`[terminal-quest] 進捗の保存に失敗しました: ${msg}\n` +
55
+ `進捗データが保持されない可能性があります。\n`);
56
+ saveWarningShown = true;
57
+ }
32
58
  }
33
59
  }
34
60
  export function resetProgress() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-quest",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "ストーリー駆動型ターミナルコマンド学習CLI - Learn terminal commands through interactive stories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",