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 +4 -0
- 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() });
|
|
@@ -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() {
|