okstra 0.4.0 → 0.6.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/src/setup.mjs ADDED
@@ -0,0 +1,243 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { resolvePaths } from "./paths.mjs";
5
+
6
+ const USAGE = `okstra setup — register the current project with okstra
7
+
8
+ Writes <PROJECT_ROOT>/.project-docs/okstra/project.json. This is the
9
+ project-level companion to 'okstra install' (which is machine-level).
10
+ Inside a Claude Code session this is also exposed as the /okstra-setup
11
+ slash command.
12
+
13
+ Usage:
14
+ okstra setup --project-id <id> Register with explicit projectId
15
+ okstra setup Use existing project.json or
16
+ prompt for projectId (TTY only)
17
+ okstra setup --project-root <path> Override PROJECT_ROOT resolution
18
+ okstra setup --yes Skip prompts; require all inputs
19
+ on the command line
20
+
21
+ Behavior:
22
+ - If project.json already exists, the projectId must match (okstra refuses
23
+ to silently rename a project). Delete the file manually if you really
24
+ want to change projectId.
25
+ - If --project-id is omitted and stdin is a TTY, you are prompted.
26
+ - If --project-id is omitted and stdin is not a TTY, the command exits
27
+ with an error (use --project-id for CI / scripts).
28
+
29
+ Exit codes:
30
+ 0 project.json present and valid after the run
31
+ 1 I/O / python failure or projectId mismatch
32
+ 2 PROJECT_ROOT could not be resolved
33
+ `;
34
+
35
+ function runProcess(cmd, args, env) {
36
+ return new Promise((resolve) => {
37
+ const child = spawn(cmd, args, {
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ env: { ...process.env, ...env },
40
+ });
41
+ let stdout = "";
42
+ let stderr = "";
43
+ child.stdout.on("data", (b) => (stdout += b.toString()));
44
+ child.stderr.on("data", (b) => (stderr += b.toString()));
45
+ child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
46
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
47
+ });
48
+ }
49
+
50
+ function parseArgs(args) {
51
+ const opts = {
52
+ projectId: null,
53
+ projectRoot: null,
54
+ yes: false,
55
+ };
56
+ for (let i = 0; i < args.length; i++) {
57
+ const a = args[i];
58
+ if (a === "--yes" || a === "-y") opts.yes = true;
59
+ else if (a === "--project-id") {
60
+ const next = args[i + 1];
61
+ if (!next || next.startsWith("--")) throw new Error("--project-id requires a value");
62
+ opts.projectId = next;
63
+ i++;
64
+ } else if (a === "--project-root") {
65
+ const next = args[i + 1];
66
+ if (!next || next.startsWith("--")) throw new Error("--project-root requires a path");
67
+ opts.projectRoot = next;
68
+ i++;
69
+ } else {
70
+ throw new Error(`unknown argument '${a}'`);
71
+ }
72
+ }
73
+ return opts;
74
+ }
75
+
76
+ async function fileExists(p) {
77
+ try {
78
+ await fs.access(p);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function prompt(question) {
86
+ return new Promise((resolve) => {
87
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
88
+ rl.question(question, (answer) => {
89
+ rl.close();
90
+ resolve(answer.trim());
91
+ });
92
+ });
93
+ }
94
+
95
+ function validateProjectId(id) {
96
+ if (!id) return "project-id is empty";
97
+ if (!/[A-Za-z0-9]/.test(id)) return "project-id must contain at least one alphanumeric character";
98
+ return null;
99
+ }
100
+
101
+ async function resolveProjectRoot(paths, explicit) {
102
+ const probe = await runProcess(
103
+ "python3",
104
+ [
105
+ "-c",
106
+ [
107
+ "import sys",
108
+ "from okstra_project import resolve_project_root, project_json_path, ResolverError",
109
+ "try:",
110
+ " pr = resolve_project_root(explicit_root=sys.argv[1], cwd=sys.argv[2])",
111
+ " print('PROJECT_ROOT', pr)",
112
+ " print('PROJECT_JSON', project_json_path(pr))",
113
+ "except ResolverError as e:",
114
+ " print('RESOLVER_ERROR', e)",
115
+ ].join("\n"),
116
+ explicit || "",
117
+ process.cwd(),
118
+ ],
119
+ { PYTHONPATH: paths.pythonpath },
120
+ );
121
+ if (probe.code !== 0) {
122
+ throw new Error(`python invocation failed: ${probe.stderr.trim() || probe.stdout.trim()}`);
123
+ }
124
+ const lines = probe.stdout.trim().split("\n");
125
+ const tagOf = (key) =>
126
+ lines
127
+ .find((l) => l.startsWith(key + " "))
128
+ ?.slice(key.length + 1)
129
+ .trim() ?? null;
130
+ const resolverError = tagOf("RESOLVER_ERROR");
131
+ if (resolverError) {
132
+ const err = new Error(resolverError);
133
+ err.code = "RESOLVER";
134
+ throw err;
135
+ }
136
+ return {
137
+ projectRoot: tagOf("PROJECT_ROOT"),
138
+ projectJsonPath: tagOf("PROJECT_JSON"),
139
+ };
140
+ }
141
+
142
+ async function upsert(paths, projectRoot, projectId) {
143
+ const probe = await runProcess(
144
+ "python3",
145
+ [
146
+ "-c",
147
+ [
148
+ "import json, sys",
149
+ "from pathlib import Path",
150
+ "from okstra_project import upsert_project_json, ResolverError",
151
+ "try:",
152
+ " result = upsert_project_json(Path(sys.argv[1]), sys.argv[2])",
153
+ " print('OK', json.dumps(result))",
154
+ "except ResolverError as e:",
155
+ " print('ERROR', e)",
156
+ ].join("\n"),
157
+ projectRoot,
158
+ projectId,
159
+ ],
160
+ { PYTHONPATH: paths.pythonpath },
161
+ );
162
+ if (probe.code !== 0) {
163
+ throw new Error(`python invocation failed: ${probe.stderr.trim() || probe.stdout.trim()}`);
164
+ }
165
+ const out = probe.stdout.trim();
166
+ if (out.startsWith("OK ")) {
167
+ return JSON.parse(out.slice(3));
168
+ }
169
+ if (out.startsWith("ERROR ")) {
170
+ throw new Error(out.slice(6));
171
+ }
172
+ throw new Error(`unexpected upsert output: ${out}`);
173
+ }
174
+
175
+ export async function run(args) {
176
+ if (args.includes("--help") || args.includes("-h")) {
177
+ process.stdout.write(USAGE);
178
+ return 0;
179
+ }
180
+
181
+ let opts;
182
+ try {
183
+ opts = parseArgs(args);
184
+ } catch (err) {
185
+ process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
186
+ return 1;
187
+ }
188
+
189
+ const paths = await resolvePaths();
190
+
191
+ let resolved;
192
+ try {
193
+ resolved = await resolveProjectRoot(paths, opts.projectRoot);
194
+ } catch (err) {
195
+ process.stderr.write(`error: could not resolve PROJECT_ROOT: ${err.message}\n`);
196
+ return err.code === "RESOLVER" ? 2 : 1;
197
+ }
198
+ const { projectRoot, projectJsonPath } = resolved;
199
+
200
+ let existing = null;
201
+ if (await fileExists(projectJsonPath)) {
202
+ try {
203
+ existing = JSON.parse(await fs.readFile(projectJsonPath, "utf8"));
204
+ } catch (err) {
205
+ process.stderr.write(`error: failed to parse ${projectJsonPath}: ${err.message}\n`);
206
+ return 1;
207
+ }
208
+ }
209
+
210
+ let projectId = opts.projectId;
211
+ if (!projectId && existing?.projectId) {
212
+ projectId = existing.projectId;
213
+ }
214
+
215
+ if (!projectId) {
216
+ if (opts.yes || !process.stdin.isTTY) {
217
+ process.stderr.write(
218
+ `error: --project-id is required (no existing project.json, not a TTY)\n`,
219
+ );
220
+ return 1;
221
+ }
222
+ process.stderr.write(`PROJECT_ROOT: ${projectRoot}\n`);
223
+ const answer = await prompt("project-id (e.g. INV-1234, fontsninja): ");
224
+ projectId = answer;
225
+ }
226
+
227
+ const invalid = validateProjectId(projectId);
228
+ if (invalid) {
229
+ process.stderr.write(`error: ${invalid}\n`);
230
+ return 1;
231
+ }
232
+
233
+ let result;
234
+ try {
235
+ result = await upsert(paths, projectRoot, projectId);
236
+ } catch (err) {
237
+ process.stderr.write(`error: ${err.message}\n`);
238
+ return 1;
239
+ }
240
+
241
+ process.stdout.write(JSON.stringify({ ok: true, ...result, projectJsonPath }, null, 2) + "\n");
242
+ return 0;
243
+ }