@formigio/fazemos-cli 0.9.0 → 0.10.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/index.js CHANGED
@@ -6646,5 +6646,560 @@ ops
6646
6646
  process.exit(1);
6647
6647
  }
6648
6648
  });
6649
+ // ── F18 — Project-scoped docs surface ────────────────────────────────
6650
+ // Spec: specs/tech/platform/F18-project-scoped-docs-surface-tech-spec.md §8
6651
+ //
6652
+ // Five subcommands expose the Project docs tree + individual file reads
6653
+ // that F17 + F18 API ship. Active-Project-aware (F15): resolve active
6654
+ // Project from config; `--project <slug>` overrides. No active Project +
6655
+ // no override → exit 1 with the uniform "requirement missing: project"
6656
+ // block (matches handleScopedError + the F17 agents subcommands).
6657
+ //
6658
+ // Server-side F18 /docs/file currently returns a flat
6659
+ // { path, file_type, content, sha, cached_at, etag }
6660
+ // envelope (see fazemos-api src/services/projectDocsService.ts). The tech
6661
+ // spec §4.2 also describes `frontmatter`, `body`, and `source` fields on
6662
+ // the response — those are Phase 3 web-surface additions not present in
6663
+ // the Phase 2 D2 API. The CLI renders the header line from the response's
6664
+ // `file_type` and the composed `<docs_root>/<path>` string (since the
6665
+ // source.path field doesn't exist yet on the wire). This is called out in
6666
+ // the handoff report as an API contract gap.
6667
+ //
6668
+ // Step-doc 422 handling (Phase 2 revision R2): when the server returns
6669
+ // 422 with code STEP_DOC_* and a `validation_errors` array, `docs cat`
6670
+ // prints the errors to stderr and exits 2 (distinct from the generic
6671
+ // error exit 1 so scripts can branch on "malformed doc" vs other failures).
6672
+ const docs = program.command('docs').description('Project docs browser. Read the Project-scoped tree and individual files that F18 exposes (Project agents, steps, and general docs) without opening the web UI.');
6673
+ /**
6674
+ * F18 — resolve {orgId, projectId, projectSlug} for a docs subcommand.
6675
+ * Mirrors F17's `requireProjectForAgents` but also surfaces the project
6676
+ * slug for display purposes (header lines, GitHub URLs, local-path probe).
6677
+ *
6678
+ * On no-active-Project + no-override: emits the uniform F15 "requirement
6679
+ * missing: project" block and exits 1. Reuses the wording from
6680
+ * handleScopedError — the KD9 convention says the CLI always prints this
6681
+ * as a triple-hint block (switch / --project / --all-projects), but
6682
+ * --all-projects has no meaning for docs subcommands (each Project has
6683
+ * its own docs tree — "all-projects" would be N API calls with no unified
6684
+ * output). We omit the --all-projects hint here on purpose.
6685
+ */
6686
+ async function requireProjectForDocs(slugOverride) {
6687
+ const orgId = requireActiveOrgOrExit();
6688
+ let projectId = null;
6689
+ let projectSlug = null;
6690
+ if (slugOverride) {
6691
+ let project = findProjectBySlug(orgId, slugOverride);
6692
+ if (!project) {
6693
+ await refreshAuthMeCache();
6694
+ project = findProjectBySlug(orgId, slugOverride);
6695
+ }
6696
+ if (!project) {
6697
+ console.error(chalk.red(`Unknown project: ${slugOverride}`));
6698
+ console.error(chalk.gray('Run: fazemos projects list'));
6699
+ process.exit(1);
6700
+ }
6701
+ projectId = project.id;
6702
+ projectSlug = project.slug;
6703
+ }
6704
+ else {
6705
+ projectId = getActiveProjectId();
6706
+ if (projectId) {
6707
+ const proj = findProjectById(orgId, projectId);
6708
+ projectSlug = proj?.slug ?? null;
6709
+ }
6710
+ }
6711
+ if (!projectId) {
6712
+ console.error(chalk.red('Error: requirement missing: project'));
6713
+ console.error('');
6714
+ console.error(chalk.gray('Set one with: fazemos projects switch <slug>'));
6715
+ console.error(chalk.gray('Or pass: --project <slug>'));
6716
+ process.exit(1);
6717
+ }
6718
+ // projectSlug can still be null if the cache is empty and we went the
6719
+ // active-project path — refresh once so the smoke-test path of "I just
6720
+ // authed in another shell" doesn't print UUIDs in headers.
6721
+ if (!projectSlug) {
6722
+ await refreshAuthMeCache();
6723
+ const proj = findProjectById(orgId, projectId);
6724
+ projectSlug = proj?.slug ?? projectId; // fall back to UUID if still unknown
6725
+ }
6726
+ return { orgId, projectId, projectSlug };
6727
+ }
6728
+ /**
6729
+ * Render the `source` block as a short header label for `docs cat`.
6730
+ * Tech spec §4.2 discriminator:
6731
+ * file → "file"
6732
+ * file + level='org' → "file (org)"
6733
+ * system_config → "system config"
6734
+ * template → "template"
6735
+ * Falls back to "file" when the response pre-dates Phase 4b and omits
6736
+ * the `source` block entirely (defensive — the API always ships it now).
6737
+ */
6738
+ function renderOriginLabel(source) {
6739
+ if (!source)
6740
+ return 'file';
6741
+ if (source.type === 'system_config')
6742
+ return 'system config';
6743
+ if (source.type === 'template')
6744
+ return 'template';
6745
+ if (source.level === 'org' || source.level === 'org_db')
6746
+ return 'file (org)';
6747
+ return 'file';
6748
+ }
6749
+ /**
6750
+ * Fetch a Project's docs tree. Returns the raw payload. Swallows nothing —
6751
+ * callers that want empty-state friendly output should branch on
6752
+ * `payload.empty` + `payload.reason`.
6753
+ */
6754
+ async function fetchDocsTree(orgId, projectId) {
6755
+ return (await api('GET', `/api/organizations/${orgId}/projects/${projectId}/docs/tree`, undefined, { noProjectHeader: true }));
6756
+ }
6757
+ /**
6758
+ * Render a flat list of tree entries as an indented text tree. The API
6759
+ * returns paths with embedded slashes ("docs/product/fazemos-concept.md")
6760
+ * and entries for both files and directories. We build a directory map
6761
+ * from the entries and walk it alphabetically for stable output.
6762
+ *
6763
+ * Output format matches tech spec §8.2 — directory names end with `/`,
6764
+ * files get 2-space indent per nesting level, and a file-type badge is
6765
+ * appended for `agent` / `step` files (not `doc` — those are the default
6766
+ * and badging every markdown file would be noisy). Size is NOT printed
6767
+ * (noisy at list scale). Directory file-counts are computed locally.
6768
+ */
6769
+ function renderDocsTree(entries) {
6770
+ const root = { name: '', fullPath: '', isDir: true, children: new Map() };
6771
+ function insert(path, entry, forceDir = false) {
6772
+ const parts = path.split('/');
6773
+ let cur = root;
6774
+ for (let i = 0; i < parts.length; i++) {
6775
+ const part = parts[i];
6776
+ const isLast = i === parts.length - 1;
6777
+ const isDir = !isLast || forceDir || entry?.type === 'dir';
6778
+ let child = cur.children.get(part);
6779
+ if (!child) {
6780
+ child = {
6781
+ name: part,
6782
+ fullPath: parts.slice(0, i + 1).join('/'),
6783
+ isDir,
6784
+ children: new Map(),
6785
+ };
6786
+ cur.children.set(part, child);
6787
+ }
6788
+ if (isLast) {
6789
+ child.entry = entry;
6790
+ // Once marked as dir (by its own entry), don't downgrade.
6791
+ if (isDir)
6792
+ child.isDir = true;
6793
+ }
6794
+ cur = child;
6795
+ }
6796
+ }
6797
+ for (const e of entries) {
6798
+ insert(e.path, e, e.type === 'dir');
6799
+ }
6800
+ function countFiles(node) {
6801
+ if (!node.isDir)
6802
+ return 1;
6803
+ let n = 0;
6804
+ for (const c of node.children.values())
6805
+ n += countFiles(c);
6806
+ return n;
6807
+ }
6808
+ const lines = [];
6809
+ function walk(node, depth) {
6810
+ const kids = Array.from(node.children.values()).sort((a, b) => {
6811
+ // Directories first, then files, both alphabetical.
6812
+ if (a.isDir !== b.isDir)
6813
+ return a.isDir ? -1 : 1;
6814
+ return a.name.localeCompare(b.name);
6815
+ });
6816
+ for (const c of kids) {
6817
+ const indent = ' '.repeat(depth);
6818
+ if (c.isDir) {
6819
+ const count = countFiles(c);
6820
+ const tag = count ? chalk.gray(` (${count} ${count === 1 ? 'file' : 'files'})`) : '';
6821
+ lines.push(`${indent}${c.name}/${tag}`);
6822
+ walk(c, depth + 1);
6823
+ }
6824
+ else {
6825
+ const ft = c.entry?.file_type;
6826
+ const badge = ft === 'agent'
6827
+ ? chalk.cyan(' [agent]')
6828
+ : ft === 'step'
6829
+ ? chalk.magenta(' [step]')
6830
+ : '';
6831
+ lines.push(`${indent}${c.name}${badge}`);
6832
+ }
6833
+ }
6834
+ }
6835
+ walk(root, 0);
6836
+ return lines;
6837
+ }
6838
+ /**
6839
+ * Format the "Synced N ago" sub-header from a cached_at ISO string. Keep
6840
+ * wording identical to UX §5.1 example ("Synced just now") when <30s.
6841
+ */
6842
+ function formatSyncedLine(cachedAtIso) {
6843
+ try {
6844
+ const cached = new Date(cachedAtIso).getTime();
6845
+ const now = Date.now();
6846
+ const seconds = Math.max(0, Math.floor((now - cached) / 1000));
6847
+ if (seconds < 30)
6848
+ return 'Synced just now';
6849
+ if (seconds < 90)
6850
+ return 'Synced 1 minute ago';
6851
+ if (seconds < 60 * 60)
6852
+ return `Synced ${Math.floor(seconds / 60)} minutes ago`;
6853
+ if (seconds < 2 * 60 * 60)
6854
+ return 'Synced 1 hour ago';
6855
+ return `Synced ${Math.floor(seconds / 3600)} hours ago`;
6856
+ }
6857
+ catch {
6858
+ return `Synced at ${cachedAtIso}`;
6859
+ }
6860
+ }
6861
+ // ── docs tree ────────────────────────────────────────────────────────
6862
+ docs
6863
+ .command('tree')
6864
+ .description('Show the Project\'s docs tree (agents/, steps/, docs/, plus the Project CLAUDE.md). Output is stable text suitable for piping.')
6865
+ .option('--include-shared', 'Also print Org-shared docs (under `docs/` and root CLAUDE.md) as a second section', false)
6866
+ .option('--project <slug>', 'Override active Project for this call')
6867
+ .action(async (opts) => {
6868
+ try {
6869
+ const { orgId, projectId, projectSlug } = await requireProjectForDocs(opts.project);
6870
+ const payload = await fetchDocsTree(orgId, projectId);
6871
+ const repoBadge = payload.repo ? `${payload.repo} at ${payload.root ?? ''}` : '(no repo configured)';
6872
+ console.log(chalk.cyan(`Docs — ${projectSlug} (${repoBadge})`));
6873
+ console.log(chalk.gray(formatSyncedLine(payload.cached_at)));
6874
+ console.log('');
6875
+ if (payload.empty) {
6876
+ if (payload.reason === 'no_docs_repo') {
6877
+ console.log(chalk.yellow('No docs repository configured.'));
6878
+ console.log(chalk.gray(' Set one with: fazemos projects update <slug> --docs-repo <owner/name> --docs-root <path>'));
6879
+ }
6880
+ else if (payload.reason === 'no_connection') {
6881
+ console.log(chalk.yellow('Project has no active GitHub Connection.'));
6882
+ console.log(chalk.gray(' Set one up with: fazemos connections ...'));
6883
+ }
6884
+ else {
6885
+ console.log(chalk.yellow('Docs tree is empty.'));
6886
+ }
6887
+ return;
6888
+ }
6889
+ for (const line of renderDocsTree(payload.tree))
6890
+ console.log(line);
6891
+ if (opts.includeShared) {
6892
+ console.log('');
6893
+ console.log(chalk.gray('── Org shared ─────────────────────────────────────────'));
6894
+ if (payload.org_shared && payload.org_shared.length) {
6895
+ for (const line of renderDocsTree(payload.org_shared))
6896
+ console.log(line);
6897
+ }
6898
+ else {
6899
+ // Phase 2 API does not yet populate org_shared (see the file-level
6900
+ // note at the top of this block). Tell the operator what we see.
6901
+ console.log(chalk.gray('(no org-shared docs returned by the API — Phase 2 ships Project-only)'));
6902
+ }
6903
+ }
6904
+ }
6905
+ catch (err) {
6906
+ handleScopedError(err);
6907
+ }
6908
+ });
6909
+ // ── docs cat ─────────────────────────────────────────────────────────
6910
+ /**
6911
+ * F18 §8 — step-doc validation failure exit code. Distinct from the
6912
+ * generic error exit 1 so operator scripts can branch on "file is
6913
+ * structurally invalid" vs "server 5xx / auth / other".
6914
+ */
6915
+ const DOCS_STEP_VALIDATION_EXIT = 2;
6916
+ docs
6917
+ .command('cat')
6918
+ .description('Print a Project doc to stdout. Adds a one-line header showing the file type and source path. On malformed step docs, prints validation errors to stderr and exits 2.')
6919
+ .argument('<path>', 'Relative path under docs_root (e.g., agents/marco.md, steps/feature-pipeline/tech-spec.md)')
6920
+ .option('--project <slug>', 'Override active Project for this call')
6921
+ .action(async (relPath, opts) => {
6922
+ try {
6923
+ const { orgId, projectId } = await requireProjectForDocs(opts.project);
6924
+ // Phase 4b: the API now ships `source.path` on every /docs/file
6925
+ // response (tech spec §4.2), so the Phase-3b tree pre-fetch that
6926
+ // compensated for the missing field is gone. `source.path` is the
6927
+ // authoritative repo-relative path. `tree` is only needed for the
6928
+ // Org-shared fallback below (kept for resilience — if the server
6929
+ // ever omits `source`, this still degrades to a helpful header).
6930
+ let payload;
6931
+ try {
6932
+ payload = (await api('GET', `/api/organizations/${orgId}/projects/${projectId}/docs/file?path=${encodeURIComponent(relPath)}`, undefined, { noProjectHeader: true }));
6933
+ }
6934
+ catch (err) {
6935
+ // Phase 2 revision R2: 422 on malformed step docs. The API
6936
+ // returns `{ error, code: STEP_DOC_*, details: { path, validation_errors: [...] } }`.
6937
+ if (err instanceof ApiError &&
6938
+ err.status === 422 &&
6939
+ typeof err.code === 'string' &&
6940
+ err.code.startsWith('STEP_DOC_')) {
6941
+ const body = (err.body ?? {});
6942
+ console.error(chalk.red(`Error: ${err.message}`));
6943
+ console.error(chalk.red(` Code: ${err.code}`));
6944
+ const ve = body.details?.validation_errors ?? [];
6945
+ if (ve.length) {
6946
+ console.error(chalk.red(' Validation errors:'));
6947
+ for (const v of ve) {
6948
+ const field = v.field ? `${v.field}: ` : '';
6949
+ console.error(chalk.red(` - ${field}${v.message ?? v.code ?? 'unknown'}`));
6950
+ }
6951
+ }
6952
+ process.exit(DOCS_STEP_VALIDATION_EXIT);
6953
+ }
6954
+ throw err;
6955
+ }
6956
+ // Header: file type + source discriminator + repo-relative path.
6957
+ // Phase 4b: `source.path` / `source.type` / `source.level` are
6958
+ // always present on a 200. The header reflects the §4.2 source
6959
+ // block so an operator piping `docs cat` to a reviewer sees
6960
+ // exactly where the bytes came from (file vs. system_config vs.
6961
+ // template-inline).
6962
+ const sourcePath = payload.source?.path ?? payload.path;
6963
+ const typeLabel = payload.file_type === 'agent'
6964
+ ? chalk.cyan('agent')
6965
+ : payload.file_type === 'step'
6966
+ ? chalk.magenta('step')
6967
+ : chalk.gray('doc');
6968
+ const originLabel = renderOriginLabel(payload.source);
6969
+ console.log(chalk.gray(`# ${typeLabel} ${chalk.gray('·')} ${originLabel}: ${sourcePath}`));
6970
+ console.log('');
6971
+ if (payload.content == null) {
6972
+ // DB-sourced response (system_config / template) per tech spec §4.2.
6973
+ // content is null; the callable body is in `body` (Phase 3+).
6974
+ if (payload.body) {
6975
+ console.log(payload.body);
6976
+ }
6977
+ else {
6978
+ console.log(chalk.gray('(no file content — this doc is sourced from system config / template inline; body field not yet exposed)'));
6979
+ }
6980
+ return;
6981
+ }
6982
+ // Trim trailing newlines; re-emit exactly one so `| grep` output
6983
+ // doesn't end with multiple blank lines.
6984
+ process.stdout.write(payload.content);
6985
+ if (!payload.content.endsWith('\n'))
6986
+ process.stdout.write('\n');
6987
+ }
6988
+ catch (err) {
6989
+ handleScopedError(err);
6990
+ }
6991
+ });
6992
+ docs
6993
+ .command('search')
6994
+ .description('Search the Project docs tree by filename/path (case-insensitive). Phase 3: path-match only; server-side full-text search is deferred per spec §8.6.')
6995
+ .argument('<query>', 'Substring to search for in file paths')
6996
+ .option('--scope <scope>', 'Scope: this-project (default) | agents | steps | all', 'this-project')
6997
+ .option('--project <slug>', 'Override active Project for this call')
6998
+ .action(async (query, opts) => {
6999
+ const scope = (opts.scope ?? 'this-project');
7000
+ if (!['this-project', 'agents', 'steps', 'all'].includes(scope)) {
7001
+ console.error(chalk.red(`Invalid --scope "${opts.scope}". Allowed: this-project, agents, steps, all`));
7002
+ process.exit(1);
7003
+ }
7004
+ try {
7005
+ const { orgId, projectId } = await requireProjectForDocs(opts.project);
7006
+ const payload = await fetchDocsTree(orgId, projectId);
7007
+ const pool = [];
7008
+ for (const e of payload.tree)
7009
+ if (e.type === 'file')
7010
+ pool.push(e);
7011
+ if (scope === 'all') {
7012
+ for (const e of payload.org_shared ?? [])
7013
+ if (e.type === 'file')
7014
+ pool.push(e);
7015
+ }
7016
+ const filtered = pool.filter((e) => {
7017
+ if (scope === 'agents')
7018
+ return e.file_type === 'agent';
7019
+ if (scope === 'steps')
7020
+ return e.file_type === 'step';
7021
+ return true; // this-project | all
7022
+ });
7023
+ const q = query.toLowerCase();
7024
+ const matches = filtered.filter((e) => e.path.toLowerCase().includes(q));
7025
+ if (!matches.length) {
7026
+ console.log(chalk.yellow(`No matches for "${query}" in scope "${scope}".`));
7027
+ return;
7028
+ }
7029
+ // Highlight the matched substring. chalk.yellow the match so it's
7030
+ // visible even in monochrome terminals (dim doesn't show well on
7031
+ // light backgrounds).
7032
+ for (const e of matches) {
7033
+ const low = e.path.toLowerCase();
7034
+ let out = '';
7035
+ let i = 0;
7036
+ while (i < e.path.length) {
7037
+ const at = low.indexOf(q, i);
7038
+ if (at < 0) {
7039
+ out += e.path.slice(i);
7040
+ break;
7041
+ }
7042
+ out += e.path.slice(i, at);
7043
+ out += chalk.yellow(e.path.slice(at, at + q.length));
7044
+ i = at + q.length;
7045
+ }
7046
+ const badge = e.file_type === 'agent'
7047
+ ? chalk.cyan(' [agent]')
7048
+ : e.file_type === 'step'
7049
+ ? chalk.magenta(' [step]')
7050
+ : '';
7051
+ console.log(` ${out}${badge}`);
7052
+ }
7053
+ console.log(chalk.gray(`\n${matches.length} ${matches.length === 1 ? 'match' : 'matches'} in scope "${scope}".`));
7054
+ }
7055
+ catch (err) {
7056
+ handleScopedError(err);
7057
+ }
7058
+ });
7059
+ // ── docs path ────────────────────────────────────────────────────────
7060
+ /**
7061
+ * F18 §8.5 — print the local workspace clone path for this Project,
7062
+ * falling back to the GitHub URL when no local clone is detected.
7063
+ *
7064
+ * Local detection heuristic:
7065
+ * 1. $FAZEMOS_WORKSPACE_PATH env var, if set + dir exists.
7066
+ * 2. `~/development/<repo-name>/` (the Fazemos convention) + dir exists.
7067
+ * If either matches, the full path is `<workspace>/<docs_root>`.
7068
+ *
7069
+ * When neither matches, we print the GitHub tree URL
7070
+ * (https://github.com/<owner>/<repo>/tree/<branch>/<root>) so `open $(fazemos docs path)`
7071
+ * on a fresh machine gracefully routes to the web UI instead of failing.
7072
+ */
7073
+ docs
7074
+ .command('path')
7075
+ .description('Print the local filesystem path to this Project\'s docs root, or the GitHub URL if no local workspace clone is detected. Useful for `cd $(fazemos docs path)`.')
7076
+ .option('--project <slug>', 'Override active Project for this call')
7077
+ .action(async (opts) => {
7078
+ try {
7079
+ const { orgId, projectId } = await requireProjectForDocs(opts.project);
7080
+ const payload = await fetchDocsTree(orgId, projectId);
7081
+ if (!payload.repo) {
7082
+ console.error(chalk.red('Project has no docs repository configured.'));
7083
+ process.exit(1);
7084
+ }
7085
+ const repo = payload.repo;
7086
+ const root = payload.root ?? '';
7087
+ const branch = payload.branch ?? 'main';
7088
+ // Local probe.
7089
+ const candidates = [];
7090
+ if (process.env.FAZEMOS_WORKSPACE_PATH)
7091
+ candidates.push(process.env.FAZEMOS_WORKSPACE_PATH);
7092
+ const home = process.env.HOME;
7093
+ const repoName = repo.split('/').pop() ?? '';
7094
+ if (home && repoName)
7095
+ candidates.push(`${home}/development/${repoName}`);
7096
+ for (const c of candidates) {
7097
+ try {
7098
+ if (existsSync(c) && statSync(c).isDirectory()) {
7099
+ // Emit to stdout only — the whole point is that `cd $(fazemos docs path)` works.
7100
+ console.log(root ? `${c}/${root}` : c);
7101
+ return;
7102
+ }
7103
+ }
7104
+ catch {
7105
+ // ignore
7106
+ }
7107
+ }
7108
+ // No local clone — print the GitHub URL.
7109
+ const url = root
7110
+ ? `https://github.com/${repo}/tree/${branch}/${root}`
7111
+ : `https://github.com/${repo}/tree/${branch}`;
7112
+ console.log(url);
7113
+ }
7114
+ catch (err) {
7115
+ handleScopedError(err);
7116
+ }
7117
+ });
7118
+ // ── docs open ────────────────────────────────────────────────────────
7119
+ /**
7120
+ * F18 §8 — open a doc. If the workspace repo is cloned locally AND we're
7121
+ * currently inside that clone (heuristic: cwd's git-root has a remote
7122
+ * matching the Project's configured docs_repo), launch $EDITOR. Otherwise
7123
+ * print the GitHub blob URL so operators on a fresh machine get a sensible
7124
+ * fallback.
7125
+ *
7126
+ * Why the "cwd is the git clone" heuristic rather than just "any clone
7127
+ * exists anywhere on disk"? Operators routinely run the CLI from inside
7128
+ * workspace shells (that's where the Fazemos CLI lives in the workflow)
7129
+ * and expect `fazemos docs open` to touch the same clone they're editing.
7130
+ * If they're in a different shell, opening GitHub in a browser is the
7131
+ * safer action than editing a clone they didn't ask about.
7132
+ */
7133
+ docs
7134
+ .command('open')
7135
+ .description('Open a Project doc. If you\'re inside the workspace repo clone, launches $EDITOR on the file; otherwise prints the GitHub blob URL.')
7136
+ .argument('<path>', 'Relative path under docs_root (e.g., agents/marco.md)')
7137
+ .option('--project <slug>', 'Override active Project for this call')
7138
+ .action(async (relPath, opts) => {
7139
+ try {
7140
+ const { orgId, projectId } = await requireProjectForDocs(opts.project);
7141
+ const payload = await fetchDocsTree(orgId, projectId);
7142
+ if (!payload.repo) {
7143
+ console.error(chalk.red('Project has no docs repository configured.'));
7144
+ process.exit(1);
7145
+ }
7146
+ const repo = payload.repo;
7147
+ const root = payload.root ?? '';
7148
+ const branch = payload.branch ?? 'main';
7149
+ // Heuristic: are we inside a git clone whose origin matches `repo`?
7150
+ let localWorkspacePath = null;
7151
+ try {
7152
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
7153
+ encoding: 'utf-8',
7154
+ stdio: ['ignore', 'pipe', 'ignore'],
7155
+ }).trim();
7156
+ if (gitRoot) {
7157
+ const originUrl = execSync('git config --get remote.origin.url', {
7158
+ encoding: 'utf-8',
7159
+ stdio: ['ignore', 'pipe', 'ignore'],
7160
+ cwd: gitRoot,
7161
+ }).trim();
7162
+ // Accept any URL form that ends with the repo's `owner/name`
7163
+ // (with optional `.git` suffix). Covers https, ssh, and git://.
7164
+ const re = new RegExp(`[:/]${repo.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}(?:\\.git)?$`);
7165
+ if (re.test(originUrl)) {
7166
+ localWorkspacePath = gitRoot;
7167
+ }
7168
+ }
7169
+ }
7170
+ catch {
7171
+ // Not a git repo, or git not available — fall through to URL.
7172
+ }
7173
+ if (localWorkspacePath) {
7174
+ const fullPath = root
7175
+ ? `${localWorkspacePath}/${root}/${relPath}`
7176
+ : `${localWorkspacePath}/${relPath}`;
7177
+ const editor = process.env.EDITOR || process.env.VISUAL;
7178
+ if (!editor) {
7179
+ console.error(chalk.red('No $EDITOR or $VISUAL configured. Set one (e.g., `export EDITOR=code`) or re-run from outside the workspace clone to get the GitHub URL.'));
7180
+ // Still print the full local path so the operator can open it manually.
7181
+ console.log(fullPath);
7182
+ process.exit(1);
7183
+ }
7184
+ try {
7185
+ execSync(`${editor} "${fullPath}"`, { stdio: 'inherit' });
7186
+ }
7187
+ catch (err) {
7188
+ const msg = err instanceof Error ? err.message : String(err);
7189
+ console.error(chalk.red(`Failed to launch editor: ${msg}`));
7190
+ process.exit(1);
7191
+ }
7192
+ return;
7193
+ }
7194
+ // Not inside the workspace clone — print the GitHub blob URL.
7195
+ const url = root
7196
+ ? `https://github.com/${repo}/blob/${branch}/${root}/${relPath}`
7197
+ : `https://github.com/${repo}/blob/${branch}/${relPath}`;
7198
+ console.log(url);
7199
+ }
7200
+ catch (err) {
7201
+ handleScopedError(err);
7202
+ }
7203
+ });
6649
7204
  program.parse();
6650
7205
  //# sourceMappingURL=index.js.map