@lenne.tech/cli 1.18.0 → 1.20.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.
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.getMarkerStatus = getMarkerStatus;
46
+ exports.installMarker = installMarker;
47
+ exports.resolveDevice = resolveDevice;
48
+ exports.runMarker = runMarker;
49
+ /**
50
+ * marker-pdf integration for the lt CLI.
51
+ *
52
+ * Marker (https://github.com/datalab-to/marker) is a PyTorch-based
53
+ * PDF → Markdown converter with first-class layout, table and equation
54
+ * support. On Apple Silicon it leverages Metal Performance Shaders
55
+ * (MPS) for GPU-accelerated inference.
56
+ *
57
+ * The CLI keeps marker in an isolated Python virtualenv under
58
+ * `~/.lt/marker/.venv/` so that:
59
+ * - we do not pollute the user's global Python environment
60
+ * - the ~3 GB of model weights are downloaded only once
61
+ * - subsequent runs start instantly (cached models)
62
+ */
63
+ const child_process_1 = require("child_process");
64
+ const fs_1 = require("fs");
65
+ const promises_1 = require("fs/promises");
66
+ const os_1 = require("os");
67
+ const path_1 = require("path");
68
+ const util_1 = require("util");
69
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
70
+ const MARKER_HOME = (0, path_1.join)((0, os_1.homedir)(), '.lt', 'marker');
71
+ const VENV_DIR = (0, path_1.join)(MARKER_HOME, '.venv');
72
+ const VENV_BIN = (0, path_1.join)(VENV_DIR, 'bin');
73
+ const VENV_PYTHON = (0, path_1.join)(VENV_BIN, 'python3');
74
+ const VENV_MARKER_SINGLE = (0, path_1.join)(VENV_BIN, 'marker_single');
75
+ const VENV_MARKER_BATCH = (0, path_1.join)(VENV_BIN, 'marker');
76
+ /**
77
+ * Detect tool availability.
78
+ */
79
+ function getMarkerStatus() {
80
+ return __awaiter(this, void 0, void 0, function* () {
81
+ const status = {
82
+ installed: false,
83
+ pythonAvailable: false,
84
+ uvAvailable: false,
85
+ venvPath: VENV_DIR,
86
+ };
87
+ try {
88
+ yield execAsync('python3 --version');
89
+ status.pythonAvailable = true;
90
+ }
91
+ catch (_a) {
92
+ // python3 missing
93
+ }
94
+ try {
95
+ yield execAsync('uv --version');
96
+ status.uvAvailable = true;
97
+ }
98
+ catch (_b) {
99
+ // uv missing — we'll fall back to python -m venv + pip
100
+ }
101
+ status.installed = (0, fs_1.existsSync)(VENV_MARKER_SINGLE) && (0, fs_1.existsSync)(VENV_MARKER_BATCH);
102
+ return status;
103
+ });
104
+ }
105
+ /**
106
+ * Install marker-pdf into ~/.lt/marker/.venv.
107
+ *
108
+ * Preferred path: `uv venv --python 3.12` + `uv pip install marker-pdf psutil`.
109
+ * Fallback: `python3 -m venv` + `pip install`.
110
+ */
111
+ function installMarker() {
112
+ return __awaiter(this, arguments, void 0, function* (opts = {}) {
113
+ var _a;
114
+ const log = (_a = opts.onProgress) !== null && _a !== void 0 ? _a : (() => { });
115
+ const status = yield getMarkerStatus();
116
+ if (status.installed) {
117
+ log('marker already installed');
118
+ return;
119
+ }
120
+ if (!status.pythonAvailable) {
121
+ throw new Error('python3 is required but not found in PATH. Install Python 3.10+ (e.g. via Homebrew: `brew install python@3.12`)');
122
+ }
123
+ yield (0, promises_1.mkdir)(MARKER_HOME, { recursive: true });
124
+ const useUv = status.uvAvailable;
125
+ // 1. Create virtualenv
126
+ if (useUv) {
127
+ log('Creating venv with uv (Python 3.12)…');
128
+ yield execAsync(`uv venv --python 3.12 "${VENV_DIR}"`, { cwd: MARKER_HOME });
129
+ }
130
+ else {
131
+ log('Creating venv with python3 (uv not found, falling back)…');
132
+ yield execAsync(`python3 -m venv "${VENV_DIR}"`, { cwd: MARKER_HOME });
133
+ }
134
+ // 2. Install marker-pdf + psutil
135
+ // psutil is needed by the marker batch CLI; it is a soft dep on some
136
+ // marker-pdf releases, so we install it explicitly.
137
+ // We use shell quoting to handle macOS "Library" / spaces in paths.
138
+ const cmd = useUv
139
+ ? `uv pip install --python "${VENV_PYTHON}" marker-pdf psutil`
140
+ : `"${VENV_BIN}/pip" install marker-pdf psutil`;
141
+ log('Installing marker-pdf + dependencies (~3 GB models will download on first run)…');
142
+ // Increase maxBuffer because pip output is large
143
+ yield execAsync(cmd, { cwd: MARKER_HOME, maxBuffer: 100 * 1024 * 1024 });
144
+ if (!(0, fs_1.existsSync)(VENV_MARKER_SINGLE)) {
145
+ throw new Error(`marker installation finished but ${VENV_MARKER_SINGLE} not found`);
146
+ }
147
+ log('marker installed successfully');
148
+ });
149
+ }
150
+ /**
151
+ * Decide the correct TORCH_DEVICE for this machine.
152
+ */
153
+ function resolveDevice(requested = 'auto') {
154
+ if (requested !== 'auto')
155
+ return requested;
156
+ if (process.platform === 'darwin' && process.arch === 'arm64')
157
+ return 'mps';
158
+ // We don't probe nvidia-smi here — let PyTorch decide CUDA at runtime
159
+ return 'cpu';
160
+ }
161
+ /**
162
+ * Run marker on a single PDF or a directory of PDFs.
163
+ */
164
+ function runMarker(inputPath, opts) {
165
+ return __awaiter(this, void 0, void 0, function* () {
166
+ var _a;
167
+ const status = yield getMarkerStatus();
168
+ if (!status.installed) {
169
+ throw new Error('marker is not installed. Run `lt tools ocr --install` first.');
170
+ }
171
+ const isDir = (0, fs_1.existsSync)(inputPath) && (yield Promise.resolve().then(() => __importStar(require('fs')))).statSync(inputPath).isDirectory();
172
+ const bin = isDir ? VENV_MARKER_BATCH : VENV_MARKER_SINGLE;
173
+ const args = [];
174
+ if (isDir) {
175
+ args.push(inputPath);
176
+ }
177
+ else {
178
+ args.push(inputPath);
179
+ }
180
+ args.push('--output_dir', opts.outputDir);
181
+ args.push('--output_format', (_a = opts.outputFormat) !== null && _a !== void 0 ? _a : 'markdown');
182
+ if (opts.disableImages)
183
+ args.push('--disable_image_extraction');
184
+ if (isDir) {
185
+ if (opts.skipExisting)
186
+ args.push('--skip_existing');
187
+ if (opts.workers && opts.workers > 0)
188
+ args.push('--workers', String(opts.workers));
189
+ }
190
+ const device = resolveDevice(opts.device);
191
+ return new Promise((resolve, reject) => {
192
+ var _a;
193
+ const proc = (0, child_process_1.spawn)(bin, args, {
194
+ env: Object.assign(Object.assign({}, process.env), { TORCH_DEVICE: device }),
195
+ stdio: ['ignore', 'pipe', 'pipe'],
196
+ });
197
+ const onLine = (_a = opts.onLine) !== null && _a !== void 0 ? _a : ((l) => process.stdout.write(`${l}\n`));
198
+ const handleStream = (stream) => {
199
+ let buf = '';
200
+ stream.on('data', (chunk) => {
201
+ var _a;
202
+ buf += chunk.toString();
203
+ const lines = buf.split(/\r?\n/);
204
+ buf = (_a = lines.pop()) !== null && _a !== void 0 ? _a : '';
205
+ for (const line of lines)
206
+ if (line)
207
+ onLine(line);
208
+ });
209
+ };
210
+ handleStream(proc.stdout);
211
+ handleStream(proc.stderr);
212
+ proc.on('close', (code) => {
213
+ resolve({ exitCode: code !== null && code !== void 0 ? code : 0 });
214
+ });
215
+ proc.on('error', reject);
216
+ });
217
+ });
218
+ }
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+ /**
3
+ * Helpers for integrating an API (`projects/api/`) or an App
4
+ * (`projects/app/`) into a fullstack workspace. Used by `lt fullstack
5
+ * init` (full-workspace flow) and by `lt fullstack add-api` /
6
+ * `lt fullstack add-app` (incremental flow on an already-existing
7
+ * workspace that only ships one half of the stack).
8
+ *
9
+ * The functions here own the small amount of "monorepo glue" that sits
10
+ * between the framework-agnostic setup primitives in
11
+ * `extensions/server.ts` and `extensions/frontend-helper.ts`:
12
+ *
13
+ * - writing `projects/api/lt.config.json` with the resolved api/
14
+ * framework mode so that follow-up generators (lt server module
15
+ * etc.) pick it up without re-probing,
16
+ * - patching the frontend `.env` with the project-specific storage
17
+ * prefix,
18
+ * - running the experimental `bun run rename` step for the
19
+ * `--next` nest-base template,
20
+ * - running the post-install format pass on the touched sub-project,
21
+ *
22
+ * They deliberately do NOT manage workspace-level concerns
23
+ * (lt-monorepo clone, root CLAUDE.md patching, top-level pnpm install,
24
+ * git initialisation). Those stay in `commands/fullstack/init.ts`
25
+ * because they only happen once per workspace creation. add-api /
26
+ * add-app run on an already-installed workspace and only need to wire
27
+ * in the new sub-project plus run a workspace-wide install.
28
+ */
29
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
30
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
31
+ return new (P || (P = Promise))(function (resolve, reject) {
32
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
33
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
34
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
35
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
36
+ });
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.detectSubProjectContext = detectSubProjectContext;
40
+ exports.detectWorkspaceLayout = detectWorkspaceLayout;
41
+ exports.findWorkspaceRoot = findWorkspaceRoot;
42
+ exports.isNonInteractive = isNonInteractive;
43
+ exports.runExperimentalNestBaseRename = runExperimentalNestBaseRename;
44
+ exports.runStandaloneWorkspaceGate = runStandaloneWorkspaceGate;
45
+ exports.shouldProceedAsStandalone = shouldProceedAsStandalone;
46
+ exports.writeApiConfig = writeApiConfig;
47
+ /**
48
+ * Detect whether the current working directory IS a sub-project of an
49
+ * lt-monorepo workspace (i.e. cwd is `projects/api/` or `projects/app/`,
50
+ * or any nested directory thereof). Returns the workspace root + the
51
+ * sub-project kind so the caller can give a precise hint like
52
+ * "you're inside projects/api — go up to the workspace root".
53
+ *
54
+ * Returns `null` when cwd is not inside such a sub-project.
55
+ */
56
+ function detectSubProjectContext(startDir, filesystem) {
57
+ const root = findWorkspaceRoot(startDir, filesystem);
58
+ if (!root)
59
+ return null;
60
+ // If the start dir IS the root, we're not inside a sub-project.
61
+ if (root === startDir)
62
+ return null;
63
+ const apiDir = filesystem.path(root, 'projects', 'api');
64
+ const appDir = filesystem.path(root, 'projects', 'app');
65
+ // Resolve absolute prefixes for a clean startsWith compare. We use
66
+ // the gluegun-resolved paths because all callers go through it.
67
+ const startAbs = filesystem.path(startDir);
68
+ if (startAbs === apiDir || startAbs.startsWith(`${apiDir}/`)) {
69
+ return { kind: 'api', subProjectDir: apiDir, workspaceRoot: root };
70
+ }
71
+ if (startAbs === appDir || startAbs.startsWith(`${appDir}/`)) {
72
+ return { kind: 'app', subProjectDir: appDir, workspaceRoot: root };
73
+ }
74
+ return null;
75
+ }
76
+ /**
77
+ * Detect what's already present in a workspace directory. Used by the
78
+ * fullstack commands to decide whether to perform a full init, only
79
+ * add the missing sub-project, or refuse because both halves already
80
+ * exist.
81
+ */
82
+ function detectWorkspaceLayout(workspaceDir, filesystem) {
83
+ const projectsDir = `${workspaceDir}/projects`;
84
+ const apiDir = `${projectsDir}/api`;
85
+ const appDir = `${projectsDir}/app`;
86
+ // `hasWorkspaceMarker` covers pnpm-workspace.yaml, npm/yarn
87
+ // `workspaces` in package.json, and the `projects/` directory
88
+ // convention. Mirrors what `findWorkspaceRoot` walks for.
89
+ const hasWorkspace = hasWorkspaceMarker(workspaceDir, filesystem);
90
+ // For "hasApi"/"hasApp", a directory existing is not enough — empty
91
+ // or stub directories from a partially-cloned monorepo would yield
92
+ // false positives. Require at least a package.json inside.
93
+ const hasApi = filesystem.exists(`${apiDir}/package.json`) === 'file';
94
+ const hasApp = filesystem.exists(`${appDir}/package.json`) === 'file';
95
+ return { hasApi, hasApp, hasWorkspace, workspaceDir };
96
+ }
97
+ /**
98
+ * Walk up from `startDir` until a workspace marker is found or the
99
+ * filesystem root is reached. Limited to 6 levels to avoid pathological
100
+ * scans on deeply-nested CWDs (e.g. inside a temp dir hierarchy).
101
+ *
102
+ * Returns the directory that contains the marker, or `null` if none
103
+ * was found within the search budget. Used by the standalone commands
104
+ * to detect the case "user is inside `projects/api/` and ran
105
+ * `lt frontend nuxt` there".
106
+ */
107
+ function findWorkspaceRoot(startDir, filesystem, maxDepth = 6) {
108
+ // Resolve to an absolute path so the parent traversal is reliable.
109
+ // gluegun's filesystem.path is a join helper; we want the OS path.
110
+ let cur = startDir;
111
+ // First check the start dir itself.
112
+ if (hasWorkspaceMarker(cur, filesystem))
113
+ return cur;
114
+ for (let i = 0; i < maxDepth; i++) {
115
+ const parent = filesystem.path(cur, '..');
116
+ if (parent === cur)
117
+ return null;
118
+ if (hasWorkspaceMarker(parent, filesystem))
119
+ return parent;
120
+ cur = parent;
121
+ }
122
+ return null;
123
+ }
124
+ /**
125
+ * Treat the caller as "non-interactive" (KI/CI) when either
126
+ * - `--noConfirm` was passed explicitly, OR
127
+ * - stdin is not a TTY (typical for `claude < script.txt`, piped CI
128
+ * runs, or any agent that captures the CLI's stdout).
129
+ *
130
+ * The TTY check catches AI agents that call `lt …` without
131
+ * `--noConfirm` (Claude Code does this) and would otherwise hit the
132
+ * `confirm()` prompt forever. Caller can opt out via `force`.
133
+ *
134
+ * Exposed so commands can derive their `noConfirm` value once and
135
+ * pass the same boolean to `shouldProceedAsStandalone`.
136
+ */
137
+ function isNonInteractive(noConfirmFlag) {
138
+ if (noConfirmFlag)
139
+ return true;
140
+ // process.stdin may be undefined in some test runners — guard.
141
+ return Boolean(process.stdin && process.stdin.isTTY === false);
142
+ }
143
+ /**
144
+ * Run the experimental `bun run rename <projectDir>` step. Only relevant
145
+ * for the `--next` nest-base template (it ships hard-coded `nest-base`
146
+ * references in package.json, README.md, portless.yml, and
147
+ * docker-compose.yml). Failures are non-fatal — the workspace is still
148
+ * usable, and the user can re-run the rename script manually.
149
+ *
150
+ * Returns true if the step was attempted (regardless of success). The
151
+ * caller decides whether to surface a warning.
152
+ */
153
+ function runExperimentalNestBaseRename(options) {
154
+ return __awaiter(this, void 0, void 0, function* () {
155
+ const { apiDir, patching, projectDir, system } = options;
156
+ // setupServerForFullstack already patched package.json to set
157
+ // `name = projectDir`. The rename planner reads that name as the
158
+ // "old" slug, which would short-circuit the rest of the rewrites
159
+ // because they still say `nest-base`. Restore the canonical
160
+ // `name = "nest-base"` first so the planner has a coherent starting
161
+ // state across all four files.
162
+ yield patching.update(`${apiDir}/package.json`, (config) => {
163
+ config.name = 'nest-base';
164
+ return config;
165
+ });
166
+ try {
167
+ yield system.run(`cd ${apiDir} && bun run rename ${projectDir}`);
168
+ return { attempted: true };
169
+ }
170
+ catch (err) {
171
+ return { attempted: true, error: err };
172
+ }
173
+ });
174
+ }
175
+ /**
176
+ * Print + prompt + decision for a standalone scaffolding command's
177
+ * workspace gate. Centralises the ~25 lines of identical logic that
178
+ * `lt server create`, `lt frontend nuxt`, and `lt frontend angular`
179
+ * each had inline.
180
+ *
181
+ * Side effects (intentionally bundled — easier to reason about as one
182
+ * unit, and the three commands all want the same shape):
183
+ * - prints the workspace-detected note + sub-project hint
184
+ * - asks the user via `confirm()` when interactive
185
+ * - prints the refusal/abort reason via `print.error` when refused
186
+ * - calls `process.exit(1)` on refusal (unless `fromGluegunMenu`)
187
+ *
188
+ * Returns `true` when the caller may proceed, `false` when the caller
189
+ * has already been told to abort and should `return` from its `run`.
190
+ *
191
+ * The shape stays narrow on purpose — adding more knobs (e.g.
192
+ * "abort message format") would just push the duplication elsewhere.
193
+ */
194
+ function runStandaloneWorkspaceGate(options) {
195
+ return __awaiter(this, void 0, void 0, function* () {
196
+ var _a;
197
+ const { cwd, filesystem, force, fromGluegunMenu, noConfirmFlag, pieceName, print: { confirm, error, info }, projectKind, suggestion, } = options;
198
+ // Sub-project hint: if the user is inside `projects/api/` or
199
+ // `projects/app/` of a workspace, point them at the root explicitly
200
+ // — the standalone path would otherwise clone a sibling tree
201
+ // *inside* the existing sub-project, which is almost never wanted.
202
+ const subProject = detectSubProjectContext(cwd, filesystem);
203
+ if (subProject) {
204
+ info('');
205
+ info(`You appear to be inside projects/${subProject.kind}/ of a workspace at ${subProject.workspaceRoot}.`);
206
+ info(` → Run \`${suggestion}\` from the workspace root instead.`);
207
+ error(`Refusing to create a standalone ${projectKind} from inside a sub-project. cd to the workspace root first.`);
208
+ if (!fromGluegunMenu)
209
+ process.exit(1);
210
+ return false;
211
+ }
212
+ const layout = detectWorkspaceLayout(cwd, filesystem);
213
+ if (!layout.hasWorkspace) {
214
+ return true; // Plain dir → standalone is fine, no gate.
215
+ }
216
+ const alreadyHas = pieceName === 'api' ? layout.hasApi : layout.hasApp;
217
+ info('');
218
+ info('Note: current directory looks like a fullstack workspace (pnpm-workspace.yaml, package.json#workspaces, or projects/).');
219
+ if (!alreadyHas) {
220
+ info(` → To integrate the ${pieceName} into this workspace, use \`${suggestion}\`.`);
221
+ }
222
+ else {
223
+ info(` → projects/${pieceName}/ already exists in this workspace; this command would create a separate ${projectKind}.`);
224
+ }
225
+ const nonInteractive = isNonInteractive(noConfirmFlag);
226
+ const userConfirmed = nonInteractive
227
+ ? undefined
228
+ : yield confirm(`Proceed with standalone ${projectKind} creation anyway?`, false);
229
+ const decision = shouldProceedAsStandalone({
230
+ force,
231
+ nonInteractive,
232
+ projectKind,
233
+ suggestion,
234
+ userConfirmed,
235
+ });
236
+ if (!decision.proceed) {
237
+ error((_a = decision.reason) !== null && _a !== void 0 ? _a : 'Aborted.');
238
+ if (!fromGluegunMenu)
239
+ process.exit(1);
240
+ return false;
241
+ }
242
+ if (nonInteractive && force) {
243
+ info(' --force set — continuing despite workspace context.');
244
+ }
245
+ info('');
246
+ return true;
247
+ });
248
+ }
249
+ /**
250
+ * Decide whether a standalone scaffolding command (`lt server create`,
251
+ * `lt frontend nuxt`, `lt frontend angular`) should run inside a
252
+ * directory that already looks like a fullstack workspace.
253
+ *
254
+ * Three modes:
255
+ *
256
+ * - **interactive** (the user can answer a prompt) — caller asks via
257
+ * `confirm()` and passes the result as `userConfirmed`.
258
+ * - **non-interactive without force** — refuse. This is the path AI
259
+ * agents and CI scripts take by default (either `--noConfirm` was
260
+ * set, or stdin isn't a TTY). Forcing them onto the workspace-
261
+ * aware command (`add-api` / `add-app`) prevents stray side-by-
262
+ * side clones that pnpm-workspace.yaml does not pick up.
263
+ * - **non-interactive with --force** — proceed, but the caller
264
+ * should log a hint so the override is visible in CI logs.
265
+ *
266
+ * The function does NOT print or prompt. It only decides; the caller
267
+ * owns the interaction surface.
268
+ */
269
+ function shouldProceedAsStandalone(options) {
270
+ const { force, nonInteractive, projectKind, suggestion, userConfirmed } = options;
271
+ // Interactive caller already gave an explicit yes/no.
272
+ if (userConfirmed !== undefined) {
273
+ return userConfirmed
274
+ ? { proceed: true }
275
+ : { proceed: false, reason: `Aborted standalone ${projectKind} creation. Use \`${suggestion}\` instead.` };
276
+ }
277
+ // Non-interactive path: refuse unless --force is set. This is the
278
+ // AI-agent / CI default — fail loud rather than silently produce a
279
+ // stray clone that does not integrate with the workspace.
280
+ if (nonInteractive && !force) {
281
+ return {
282
+ proceed: false,
283
+ reason: `Refusing to create a standalone ${projectKind} inside an existing fullstack workspace ` +
284
+ `(non-interactive caller detected). ` +
285
+ `Use \`${suggestion}\` for the workspace-aware flow, or pass --force to override (rare).`,
286
+ };
287
+ }
288
+ // Non-interactive + force: caller knows what they want.
289
+ return { proceed: true };
290
+ }
291
+ /**
292
+ * Write `projects/api/lt.config.json` with the apiMode and frameworkMode
293
+ * baked in so follow-up generators (lt server module, addProp,
294
+ * permissions) can pick the correct controller type and detect vendor
295
+ * mode without re-probing the file tree.
296
+ *
297
+ * Idempotent: overwrites whatever was at `lt.config.json` so a re-run
298
+ * after an apiMode change reflects the new value.
299
+ */
300
+ function writeApiConfig(options) {
301
+ const { apiDir, apiMode, filesystem, frameworkMode } = options;
302
+ filesystem.write(filesystem.path(apiDir, 'lt.config.json'), {
303
+ commands: {
304
+ server: {
305
+ module: {
306
+ controller: apiMode,
307
+ },
308
+ },
309
+ },
310
+ meta: {
311
+ apiMode,
312
+ frameworkMode,
313
+ version: '1.0.0',
314
+ },
315
+ }, { jsonIndent: 2 });
316
+ }
317
+ /**
318
+ * Probe a single directory for workspace markers. Pure helper used by
319
+ * `detectWorkspaceLayout` and `findWorkspaceRoot`.
320
+ *
321
+ * Recognised markers (any one is sufficient):
322
+ * - `pnpm-workspace.yaml` — pnpm workspace
323
+ * - `package.json` with `workspaces` field — npm/yarn/bun workspaces
324
+ * - `projects/` directory — lt-monorepo convention
325
+ *
326
+ * Returns false for `node_modules`-style directories that may contain
327
+ * a stray `package.json` with `workspaces`.
328
+ */
329
+ function hasWorkspaceMarker(dir, filesystem) {
330
+ var _a;
331
+ if (filesystem.exists(`${dir}/pnpm-workspace.yaml`) === 'file')
332
+ return true;
333
+ if (filesystem.exists(`${dir}/projects`) === 'dir')
334
+ return true;
335
+ const pkgPath = `${dir}/package.json`;
336
+ if (filesystem.exists(pkgPath) !== 'file')
337
+ return false;
338
+ const pkg = filesystem.read(pkgPath, 'json');
339
+ if (!pkg)
340
+ return false;
341
+ // npm/yarn workspaces: `workspaces` is either an array of globs
342
+ // (`["packages/*"]`) or an object with a `packages` array
343
+ // (yarn classic). Both count.
344
+ const ws = pkg.workspaces;
345
+ if (Array.isArray(ws) && ws.length > 0)
346
+ return true;
347
+ if (ws && typeof ws === 'object' && Array.isArray(ws.packages)) {
348
+ return ((_a = ws.packages) !== null && _a !== void 0 ? _a : []).length > 0;
349
+ }
350
+ return false;
351
+ }