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 +4 -0
- package/dist/data/stories/00-beginner-pc.js +22 -22
- package/dist/data/stories/k1-treasure-hunt.js +21 -21
- package/dist/engine/CommandHandler.d.ts +2 -2
- package/dist/engine/CommandHandler.js +8 -1
- package/dist/engine/VirtualFS.js +8 -0
- package/dist/engine/commands/chmod.js +2 -1
- package/dist/engine/commands/cut.js +2 -1
- package/dist/engine/commands/echo.js +4 -0
- package/dist/engine/commands/git.js +14 -3
- package/dist/engine/commands/head.js +2 -1
- package/dist/engine/commands/index.js +0 -4
- package/dist/engine/commands/mkdir.js +2 -1
- package/dist/engine/commands/mv.js +2 -1
- package/dist/engine/commands/rm.js +2 -1
- package/dist/engine/commands/sort.js +4 -3
- package/dist/engine/commands/tail.js +2 -1
- package/dist/engine/commands/touch.js +2 -1
- package/dist/engine/commands/uniq.js +2 -1
- package/dist/engine/commands/wc.js +2 -1
- package/dist/screens/TerminalScreen.js +14 -10
- package/dist/state/ProgressStore.js +34 -8
- package/package.json +1 -1
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
|
|
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: '
|
|
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: '
|
|
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: 'sort
|
|
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
|
|
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
|
|
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: '
|
|
833
|
-
{ level: 2, text: 'cut コマンドで列を取り出せます。-d,
|
|
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)
|
|
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: '
|
|
858
|
+
description: '集計スクリプトに「動かしていい」の許可をつけよう(chmod コマンド)',
|
|
859
859
|
checks: [{ type: 'command_executed', command: 'chmod' }],
|
|
860
860
|
hints: [
|
|
861
|
-
{ level: 1, text: '
|
|
862
|
-
{ level: 2, text: '
|
|
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
|
|
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: 'sort
|
|
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
|
|
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
|
|
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: '
|
|
912
|
-
goal: 'chmod
|
|
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
|
|
917
|
+
explanation: 'chmod はファイルの「できること」をかえるコマンドだよ。+x をつけると「プログラムとして動かしてOK」になるよ。「change mode」のりゃくだよ。',
|
|
918
918
|
},
|
|
919
|
-
narrative: 'castle
|
|
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
|
|
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
|
|
966
|
+
explanation: 'git log はいままでの変更きろくをじゅんばんにひょうじするコマンドだよ。「いつ・だれが・なにをした」がわかるよ。git status はいまの状態をたしかめるコマンドだよ。',
|
|
967
967
|
},
|
|
968
|
-
narrative: 'base
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/engine/VirtualFS.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
12
|
+
return { output: '', error: `mv: ${msg}` };
|
|
12
13
|
}
|
|
13
14
|
return { output: '' };
|
|
14
15
|
}
|
|
@@ -59,7 +59,8 @@ export function sort(fs, args) {
|
|
|
59
59
|
content = fs.readFile(files[0]);
|
|
60
60
|
}
|
|
61
61
|
catch (e) {
|
|
62
|
-
|
|
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)
|
|
87
|
-
const nb = parseFloat(kb)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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() {
|