claude-nomad 0.25.0 → 0.25.2

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/commands.doctor.check-shared.scan.ts +158 -0
  5. package/src/commands.doctor.check-shared.ts +58 -189
  6. package/src/commands.doctor.checks.pathmap.ts +101 -0
  7. package/src/commands.doctor.checks.repo.ts +133 -0
  8. package/src/commands.doctor.checks.repository.ts +105 -0
  9. package/src/commands.doctor.checks.settings.ts +88 -0
  10. package/src/commands.doctor.format.ts +18 -0
  11. package/src/commands.doctor.ts +10 -7
  12. package/src/commands.drop-session.git.ts +81 -0
  13. package/src/commands.drop-session.ts +79 -138
  14. package/src/commands.pull.ts +3 -2
  15. package/src/commands.push.allowlist.ts +119 -0
  16. package/src/commands.push.ts +6 -121
  17. package/src/commands.update.git.ts +90 -0
  18. package/src/commands.update.resolve.ts +138 -0
  19. package/src/commands.update.test-helpers.git.ts +107 -0
  20. package/src/commands.update.ts +4 -221
  21. package/src/diff.ts +2 -1
  22. package/src/extras-sync.diff.ts +40 -0
  23. package/src/extras-sync.guards.ts +52 -0
  24. package/src/extras-sync.ts +146 -236
  25. package/src/init.classify.ts +1 -1
  26. package/src/init.snapshot.ts +3 -1
  27. package/src/init.ts +2 -1
  28. package/src/links.ts +3 -10
  29. package/src/nomad.dispatch.ts +25 -0
  30. package/src/nomad.help.ts +43 -0
  31. package/src/nomad.ts +6 -68
  32. package/src/preview.ts +2 -1
  33. package/src/push-gitleaks.scan.ts +115 -0
  34. package/src/push-gitleaks.ts +50 -106
  35. package/src/remap.ts +3 -1
  36. package/src/resume.ts +2 -1
  37. package/src/update.fork-extras.ts +2 -1
  38. package/src/utils.fs.ts +152 -0
  39. package/src/utils.json.ts +55 -0
  40. package/src/utils.lockfile.ts +168 -0
  41. package/src/utils.ts +0 -327
  42. package/src/commands.doctor.checks.ts +0 -350
@@ -0,0 +1,133 @@
1
+ import { existsSync, lstatSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import {
5
+ blue,
6
+ cyan,
7
+ dim,
8
+ failGlyph,
9
+ green,
10
+ infoGlyph,
11
+ okGlyph,
12
+ red,
13
+ warnGlyph,
14
+ yellow,
15
+ } from './color.ts';
16
+ import { CLAUDE_HOME, HOST, REPO_HOME, SHARED_LINKS } from './config.ts';
17
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
18
+ import { classifyRepoState, reasonForPartial } from './init.classify.ts';
19
+
20
+ /**
21
+ * Host- and repo-state reporters for `cmdDoctor`. Each helper appends one or
22
+ * more items to its target `DoctorSection` (via `addItem`) and signals failure
23
+ * by setting `process.exitCode = 1`. Items go to stdout at render time through
24
+ * `renderDoctor` in `commands.doctor.format`; nothing here writes to stderr
25
+ * (read-only doctor contract: FAIL lines stay on stdout so a piped
26
+ * `nomad doctor 2>/dev/null` does not lose them).
27
+ */
28
+
29
+ /**
30
+ * True when the `NOMAD_REPO` env override is set to a non-empty value.
31
+ * Mirrors the `||` empty-string-fallthrough semantics of `REPO_HOME` itself
32
+ * (see `src/config.ts`): an unset env, or `export NOMAD_REPO=`, both return
33
+ * false because the default fallback fires. Reads `process.env.NOMAD_REPO`
34
+ * directly so a set-but-empty value is distinguishable from "set to the
35
+ * default path"; reading via the imported `REPO_HOME` constant cannot make
36
+ * that distinction. Exposed for `reportRepoState`; not for general use.
37
+ */
38
+ export function isOverrideActive(): boolean {
39
+ return Boolean(process.env.NOMAD_REPO);
40
+ }
41
+
42
+ /**
43
+ * Pushes the host identity (info) and the two key path lines (repo and
44
+ * claude-home) with gutter glyphs. Path presence is reported via warnGlyph
45
+ * (not failGlyph) so an absent CLAUDE_HOME does not flip sectionFailed to
46
+ * decorate the Host header with `✘`. The authoritative empty-repo FAIL is
47
+ * owned by reportRepoState; these two lines remain informational and do
48
+ * NOT mutate process.exitCode.
49
+ */
50
+ export function reportHostAndPaths(section: DoctorSection): void {
51
+ addItem(section, `${dim(infoGlyph)} host: ${cyan(HOST)}`);
52
+ addItem(
53
+ section,
54
+ `${existsSync(REPO_HOME) ? green(okGlyph) : yellow(warnGlyph)} repo: ${blue(REPO_HOME)}`,
55
+ );
56
+ addItem(
57
+ section,
58
+ `${existsSync(CLAUDE_HOME) ? green(okGlyph) : yellow(warnGlyph)} claude home: ${blue(CLAUDE_HOME)}`,
59
+ );
60
+ }
61
+
62
+ /** Emits the repo-state status line derived from classifyRepoState (okGlyph/warnGlyph/failGlyph). When `NOMAD_REPO` is active, all three branches receive a ` (NOMAD_REPO)` suffix so the env override is visible whatever the repo state. FAIL signals via process.exitCode. */
63
+ export function reportRepoState(section: DoctorSection): void {
64
+ const state = classifyRepoState(REPO_HOME, HOST);
65
+ // Computed once so populated/partial/empty branches share the same
66
+ // annotation. Leading space before `(` keeps the line readable on every
67
+ // branch; empty string produces zero visual change when the override is
68
+ // not in play, matching SPEC §5 (acceptance: unset env -> no annotation).
69
+ const overrideLabel = isOverrideActive() ? ' (NOMAD_REPO)' : '';
70
+ if (state === 'populated') {
71
+ addItem(section, `${green(okGlyph)} repo state: populated${overrideLabel}`);
72
+ } else if (state === 'partial') {
73
+ addItem(
74
+ section,
75
+ `${yellow(warnGlyph)} repo state: partial ${reasonForPartial(REPO_HOME, HOST)}${overrideLabel}`,
76
+ );
77
+ } else {
78
+ addItem(
79
+ section,
80
+ `${red(failGlyph)} repo state: empty - run 'nomad init' to scaffold${overrideLabel}`,
81
+ );
82
+ process.exitCode = 1;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Emits a per-entry status line for each name in SHARED_LINKS
88
+ * (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs via
89
+ * process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
90
+ * that vanishes or becomes unreadable between the probe and the stat yields a
91
+ * row instead of an unhandled throw that aborts the whole doctor run. A symlink
92
+ * whose target cannot be resolved is a WARN (broken-symlink for a missing
93
+ * target, target-unreadable otherwise), never a healthy OK, so a dangling or
94
+ * unreadable link is not masked.
95
+ */
96
+ export function reportSharedLinks(section: DoctorSection): void {
97
+ for (const name of SHARED_LINKS) {
98
+ const p = join(CLAUDE_HOME, name);
99
+ let stat;
100
+ try {
101
+ stat = lstatSync(p);
102
+ } catch (err) {
103
+ const code = (err as NodeJS.ErrnoException).code;
104
+ if (code === 'ENOENT') {
105
+ addItem(section, `${yellow(warnGlyph)} ${name}: missing`);
106
+ } else {
107
+ addItem(section, `${red(failGlyph)} ${name}: could not stat (${String(code)})`);
108
+ process.exitCode = 1;
109
+ }
110
+ continue;
111
+ }
112
+ if (stat.isSymbolicLink()) {
113
+ try {
114
+ // statSync follows the link; a throw means the target does not resolve.
115
+ statSync(p);
116
+ addItem(section, `${green(okGlyph)} ${name}: symlink`);
117
+ } catch (err) {
118
+ const code = (err as NodeJS.ErrnoException).code;
119
+ if (code === 'ENOENT') {
120
+ addItem(section, `${yellow(warnGlyph)} ${name}: broken symlink (target missing)`);
121
+ } else {
122
+ addItem(
123
+ section,
124
+ `${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
125
+ );
126
+ }
127
+ }
128
+ } else {
129
+ addItem(section, `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`);
130
+ process.exitCode = 1;
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,105 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+
5
+ import {
6
+ blue,
7
+ cyan,
8
+ dim,
9
+ failGlyph,
10
+ green,
11
+ infoGlyph,
12
+ okGlyph,
13
+ red,
14
+ warnGlyph,
15
+ yellow,
16
+ } from './color.ts';
17
+ import { REPO_HOME } from './config.ts';
18
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
19
+ import { findGitlinks } from './push-checks.ts';
20
+ import { gitStatusPorcelainZ } from './utils.ts';
21
+
22
+ /**
23
+ * Repository-state reporters for `cmdDoctor`: the gitleaks presence probe, the
24
+ * nested-gitlink scan of `shared/`, the remote-origin line, and the
25
+ * rebase-clean-tree WARN. Each helper appends items to its target
26
+ * `DoctorSection` and signals failure by setting `process.exitCode = 1`.
27
+ * Read-only: FAIL lines stay on stdout.
28
+ */
29
+
30
+ /**
31
+ * Probes for gitleaks on PATH; emits okGlyph with version, or failGlyph with
32
+ * ENOENT vs other-error distinction (sets exitCode=1). Returns `true` when a
33
+ * usable binary was found so the caller can skip a redundant second `version`
34
+ * probe (e.g. the `--check-shared` Shared scan section).
35
+ */
36
+ export function reportGitleaksProbe(section: DoctorSection): boolean {
37
+ try {
38
+ const v = execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] })
39
+ .toString()
40
+ .trim();
41
+ addItem(section, `${green(okGlyph)} gitleaks: ${dim(v)}`);
42
+ return true;
43
+ } catch (err) {
44
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
45
+ addItem(section, `${red(failGlyph)} gitleaks: not on PATH (required for nomad push)`);
46
+ } else {
47
+ addItem(section, `${red(failGlyph)} gitleaks: probe failed: ${(err as Error).message}`);
48
+ }
49
+ process.exitCode = 1;
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /** Walks shared/ for nested .git gitlinks; emits failGlyph per gitlink found (sets exitCode=1), okGlyph when none. */
55
+ export function reportGitlinks(section: DoctorSection): void {
56
+ const sharedDir = join(REPO_HOME, 'shared');
57
+ if (existsSync(sharedDir)) {
58
+ const gitlinks = findGitlinks(sharedDir);
59
+ for (const p of gitlinks) {
60
+ const rel = relative(REPO_HOME, p);
61
+ addItem(
62
+ section,
63
+ `${red(failGlyph)} gitlink: ${blue(rel)} would push as submodule (run: rm -rf ${rel} or remove the nested repo)`,
64
+ );
65
+ }
66
+ if (gitlinks.length > 0) {
67
+ process.exitCode = 1;
68
+ } else {
69
+ addItem(section, `${green(okGlyph)} gitlink scan: no nested .git in shared/`);
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Pushes the `git remote get-url origin` line or a `not configured` informational line. */
75
+ export function reportRemote(section: DoctorSection): void {
76
+ try {
77
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
78
+ cwd: REPO_HOME,
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ })
81
+ .toString()
82
+ .trim();
83
+ addItem(section, `${dim(infoGlyph)} remote origin: ${cyan(url)}`);
84
+ } catch {
85
+ addItem(section, `${dim(infoGlyph)} remote origin: not configured`);
86
+ }
87
+ }
88
+
89
+ /** WARNs when ~/claude-nomad/ has uncommitted changes (autostash territory for push). */
90
+ export function reportRebaseClean(section: DoctorSection): void {
91
+ try {
92
+ const status = gitStatusPorcelainZ(REPO_HOME);
93
+ if (status.length > 0) {
94
+ addItem(
95
+ section,
96
+ `${yellow(warnGlyph)} ${blue('~/claude-nomad/')} has uncommitted changes (nomad push will --autostash these)`,
97
+ );
98
+ }
99
+ } catch {
100
+ // gitStatusPorcelainZ failure on a missing or non-repo REPO_HOME is
101
+ // already surfaced by reportHostAndPaths (warnGlyph on the `repo:` line
102
+ // when the directory is absent) and reportRepoState ('empty' FAIL when
103
+ // the scaffold is absent). Swallowing here avoids double-reporting.
104
+ }
105
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import {
5
+ blue,
6
+ dim,
7
+ failGlyph,
8
+ green,
9
+ infoGlyph,
10
+ okGlyph,
11
+ red,
12
+ warnGlyph,
13
+ yellow,
14
+ } from './color.ts';
15
+ import { HOST, KNOWN_SETTINGS_KEYS, REPO_HOME, CLAUDE_HOME } from './config.ts';
16
+ import { addItem, readJsonSafe, type DoctorSection } from './commands.doctor.format.ts';
17
+
18
+ /**
19
+ * Settings reporters for `cmdDoctor`: the shared base, the local
20
+ * `settings.json` schema check, and the host-override diagnostic. Each helper
21
+ * appends items to its target `DoctorSection` and signals failure by setting
22
+ * `process.exitCode = 1`. Read-only: FAIL lines stay on stdout (a piped
23
+ * `nomad doctor 2>/dev/null` keeps them).
24
+ */
25
+
26
+ /** Loads shared/settings.base.json; on missing or malformed, records a FAIL item in the supplied section. Returns the parsed object or null. */
27
+ export function loadBaseSettings(section: DoctorSection): Record<string, unknown> | null {
28
+ const basePath = join(REPO_HOME, 'shared', 'settings.base.json');
29
+ if (!existsSync(basePath)) {
30
+ addItem(section, `${red(failGlyph)} shared/settings.base.json missing at ${blue(basePath)}`);
31
+ process.exitCode = 1;
32
+ return null;
33
+ }
34
+ return readJsonSafe<Record<string, unknown>>(basePath, basePath, section);
35
+ }
36
+
37
+ /** Loads ~/.claude/settings.json when present and emits the schema status (okGlyph for known-keys-only, warnGlyph when unknown keys are present); returns the parsed object or null. */
38
+ export function loadAndReportSettings(section: DoctorSection): Record<string, unknown> | null {
39
+ const settingsPath = join(CLAUDE_HOME, 'settings.json');
40
+ if (!existsSync(settingsPath)) return null;
41
+ const settings = readJsonSafe<Record<string, unknown>>(settingsPath, settingsPath, section);
42
+ if (settings === null) return null;
43
+ const unknownKeys = Object.keys(settings).filter((k) => !KNOWN_SETTINGS_KEYS.has(k));
44
+ if (unknownKeys.length > 0) {
45
+ addItem(
46
+ section,
47
+ `${yellow(warnGlyph)} settings.json has unknown keys (schema drift?): ${unknownKeys.join(', ')}`,
48
+ );
49
+ } else {
50
+ addItem(section, `${green(okGlyph)} settings.json schema: known keys only`);
51
+ }
52
+ return settings;
53
+ }
54
+
55
+ /** Emits the host-override status: okGlyph when no host file is needed (base-only matches settings), failGlyph on drift without a host file (with candidate list), or okGlyph path when the host file parses. */
56
+ export function reportHostOverrides(
57
+ section: DoctorSection,
58
+ base: Record<string, unknown> | null,
59
+ settings: Record<string, unknown> | null,
60
+ ): void {
61
+ const hostFile = join(REPO_HOME, 'hosts', `${HOST}.json`);
62
+ let drift: string[] = [];
63
+ if (base !== null && settings !== null) {
64
+ const baseKeys = new Set(Object.keys(base));
65
+ drift = Object.keys(settings).filter((k) => !baseKeys.has(k));
66
+ }
67
+ if (existsSync(hostFile)) {
68
+ if (readJsonSafe<Record<string, unknown>>(hostFile, hostFile, section) !== null) {
69
+ addItem(section, `${green(okGlyph)} host overrides: ${blue(hostFile)}`);
70
+ }
71
+ } else if (drift.length > 0) {
72
+ addItem(
73
+ section,
74
+ `${red(failGlyph)} no hosts/${HOST}.json AND settings.json has unbased keys ${JSON.stringify(drift)}`,
75
+ );
76
+ const hostsDir = join(REPO_HOME, 'hosts');
77
+ if (existsSync(hostsDir)) {
78
+ const cands = readdirSync(hostsDir).filter((f) => f.endsWith('.json'));
79
+ if (cands.length > 0) addItem(section, `${dim(infoGlyph)} candidates: ${cands.join(', ')}`);
80
+ }
81
+ process.exitCode = 1;
82
+ } else {
83
+ addItem(
84
+ section,
85
+ `${green(okGlyph)} host overrides: none (base-only is fine, no settings drift)`,
86
+ );
87
+ }
88
+ }
@@ -1,4 +1,5 @@
1
1
  import { failGlyph, red } from './color.ts';
2
+ import { readJson } from './utils.json.ts';
2
3
 
3
4
  /**
4
5
  * Bare `failGlyph` codepoint (`✗`, U+2717) without any WSL padding the
@@ -38,6 +39,23 @@ export function addItem(s: DoctorSection, text: string): void {
38
39
  s.items.push(text);
39
40
  }
40
41
 
42
+ /**
43
+ * Tolerant JSON reader for `cmdDoctor`. Doctor reads three JSON files
44
+ * (`settings.json`, `settings.base.json`, `path-map.json`); a malformed
45
+ * input must not throw mid-output (user would lose every line below it).
46
+ * Returns `null` on parse failure, records a FAIL item in the supplied
47
+ * section, and sets `process.exitCode = 1` so scripts can gate on the result.
48
+ */
49
+ export function readJsonSafe<T>(path: string, label: string, section: DoctorSection): T | null {
50
+ try {
51
+ return readJson<T>(path);
52
+ } catch (err) {
53
+ addItem(section, `${red(failGlyph)} ${label} malformed JSON: ${(err as Error).message}`);
54
+ process.exitCode = 1;
55
+ return null;
56
+ }
57
+ }
58
+
41
59
  /**
42
60
  * True when any item in the section contains the FAIL glyph.
43
61
  * Color-wrapped failGlyph (`✗`) still contains the
@@ -1,17 +1,20 @@
1
+ import {
2
+ reportHostAndPaths,
3
+ reportRepoState,
4
+ reportSharedLinks,
5
+ } from './commands.doctor.checks.repo.ts';
1
6
  import {
2
7
  loadAndReportSettings,
3
8
  loadBaseSettings,
9
+ reportHostOverrides,
10
+ } from './commands.doctor.checks.settings.ts';
11
+ import { reportNeverSync, reportPathMap } from './commands.doctor.checks.pathmap.ts';
12
+ import {
4
13
  reportGitleaksProbe,
5
14
  reportGitlinks,
6
- reportHostAndPaths,
7
- reportHostOverrides,
8
- reportNeverSync,
9
- reportPathMap,
10
15
  reportRebaseClean,
11
16
  reportRemote,
12
- reportRepoState,
13
- reportSharedLinks,
14
- } from './commands.doctor.checks.ts';
17
+ } from './commands.doctor.checks.repository.ts';
15
18
  import { reportCheckShared } from './commands.doctor.check-shared.ts';
16
19
  import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
17
20
  import { renderDoctor, section } from './commands.doctor.format.ts';
@@ -0,0 +1,81 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ import { REPO_HOME } from './config.ts';
4
+
5
+ /**
6
+ * Expand a repo-relative directory into its staged entries via
7
+ * `git ls-files -z -- <dirRel>` (argv-array form, NUL-split for path
8
+ * safety). Returns repo-relative POSIX paths for every staged file under
9
+ * the directory, or an empty array when none are staged or `git` fails
10
+ * (missing/corrupt index); the caller then falls through to the existing
11
+ * per-entry idempotency guard rather than escalating to a FATAL.
12
+ *
13
+ * @param dirRel Repo-relative directory path (`shared/projects/<logical>/<id>`).
14
+ */
15
+ export function expandStagedDir(dirRel: string): string[] {
16
+ try {
17
+ const out = execFileSync('git', ['ls-files', '-z', '--', dirRel], {
18
+ cwd: REPO_HOME,
19
+ stdio: ['ignore', 'pipe', 'pipe'],
20
+ });
21
+ return out
22
+ .toString()
23
+ .split('\0')
24
+ .filter((p) => p !== '');
25
+ } catch {
26
+ /* c8 ignore next -- defensive: a git ls-files failure falls back to an empty list */
27
+ return [];
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Is `rel` (repo-relative path) present in the HEAD tree? Wraps
33
+ * `git cat-file -e HEAD:<rel>`: exit 0 means tracked in HEAD,
34
+ * non-zero means either no HEAD exists yet (empty repo) or the path is
35
+ * only in the index (newly-staged-not-in-HEAD). `git ls-files
36
+ * --error-unmatch` is NOT a HEAD-presence check; it matches anything in
37
+ * the index too, which would misclassify newly-staged paths.
38
+ *
39
+ * The catch deliberately collapses three cases to `false`: (a) HEAD has
40
+ * no commit yet (fresh `git init`), (b) HEAD is unresolvable / corrupt
41
+ * (e.g., `.git/refs/heads/main` was deleted manually), and (c) the
42
+ * specific path simply does not exist in a valid HEAD. Git produces the
43
+ * same exit 128 and the same stderr (`fatal: invalid object name 'HEAD'`)
44
+ * for (a) and (b), so a probe-based distinction would require additional
45
+ * git-plumbing reads (`rev-parse --verify HEAD`, `.git/refs/heads/`
46
+ * inspection) that are brittle and break the empty-repo path every
47
+ * existing test runs through. The downstream `git rm --cached -f` is
48
+ * idempotent and produces the user-intended unstage outcome regardless
49
+ * of which case fired, so the collapsed return is intentional. Repo
50
+ * health belongs to `nomad doctor`, not drop-session.
51
+ */
52
+ export function isTrackedInHead(rel: string): boolean {
53
+ try {
54
+ execFileSync('git', ['cat-file', '-e', `HEAD:${rel}`], {
55
+ cwd: REPO_HOME,
56
+ stdio: ['ignore', 'pipe', 'pipe'],
57
+ });
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Is `rel` present in the index at all? Wraps `git ls-files -- <rel>` and
66
+ * checks for non-empty stdout. Required for the Pitfall 7 idempotency
67
+ * guard: a second invocation on the same id finds the file on disk (per
68
+ * `existsSync`) but absent from the index, and must NOT call `git rm
69
+ * --cached` on it (which would fail with exit 128).
70
+ */
71
+ export function isInIndex(rel: string): boolean {
72
+ try {
73
+ const out = execFileSync('git', ['ls-files', '--', rel], {
74
+ cwd: REPO_HOME,
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ });
77
+ return out.toString().trim() !== '';
78
+ } catch {
79
+ return false;
80
+ }
81
+ }