terminal-quest 1.2.2 → 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() });
@@ -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.2",
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",