qavor 0.1.8 → 0.1.10

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
@@ -4,87 +4,15 @@
4
4
  import process2 from "process";
5
5
  import { Command } from "commander";
6
6
 
7
- // src/util/exit-codes.ts
8
- var ExitCode = {
9
- Ok: 0,
10
- UserError: 1,
11
- ManifestError: 2,
12
- RuntimeError: 3
13
- };
14
- var QavorError = class extends Error {
15
- exitCode;
16
- constructor(message, exitCode = ExitCode.RuntimeError) {
17
- super(message);
18
- this.name = "QavorError";
19
- this.exitCode = exitCode;
20
- }
21
- };
22
- var UserError = class extends QavorError {
23
- constructor(message) {
24
- super(message, ExitCode.UserError);
25
- this.name = "UserError";
26
- }
27
- };
28
- var ManifestError = class extends QavorError {
29
- constructor(message) {
30
- super(message, ExitCode.ManifestError);
31
- this.name = "ManifestError";
32
- }
33
- };
34
- var RuntimeFailure = class extends QavorError {
35
- constructor(message) {
36
- super(message, ExitCode.RuntimeError);
37
- this.name = "RuntimeFailure";
38
- }
39
- };
40
-
41
- // src/util/logger.ts
42
- import pino from "pino";
43
- var rootLogger = null;
44
- function configureLogger(opts) {
45
- const level = opts.verbose ? "debug" : "info";
46
- const stderrIsTty = Boolean(process.stderr.isTTY);
47
- if (opts.json || !stderrIsTty) {
48
- rootLogger = pino({ level }, pino.destination({ fd: 2, sync: true }));
49
- } else {
50
- rootLogger = pino({
51
- level,
52
- transport: {
53
- target: "pino-pretty",
54
- options: {
55
- destination: 2,
56
- colorize: stderrIsTty,
57
- translateTime: false,
58
- ignore: "pid,hostname,time",
59
- messageFormat: "{msg}",
60
- singleLine: false,
61
- sync: true
62
- }
63
- }
64
- });
65
- }
66
- return rootLogger;
67
- }
68
- function getLogger() {
69
- if (!rootLogger) {
70
- rootLogger = pino({ level: "info" });
71
- }
72
- return rootLogger;
73
- }
74
- function emit(text) {
75
- process.stdout.write(text + "\n");
76
- }
77
- function emitJson(payload) {
78
- process.stdout.write(JSON.stringify(payload) + "\n");
79
- }
80
-
81
- // src/cli/commands/init.ts
82
- import path6 from "path";
7
+ // src/cli/commands/doctor.ts
8
+ import fs6 from "fs/promises";
9
+ import path8 from "path";
10
+ import { execa as execa2 } from "execa";
83
11
 
84
- // src/workspace/init.ts
85
- import { createHash as createHash2 } from "crypto";
86
- import fs4 from "fs/promises";
87
- import path5 from "path";
12
+ // src/manifest/discovery.ts
13
+ import fs3 from "fs/promises";
14
+ import path3 from "path";
15
+ import pMap from "p-map";
88
16
 
89
17
  // src/util/fs.ts
90
18
  import { createHash } from "crypto";
@@ -124,7 +52,8 @@ async function readJsonFile(target) {
124
52
  }
125
53
  async function writeJsonFile(target, value) {
126
54
  await ensureDir(path.dirname(target));
127
- await fs.writeFile(target, JSON.stringify(value, null, 2) + "\n", "utf8");
55
+ await fs.writeFile(target, `${JSON.stringify(value, null, 2)}
56
+ `, "utf8");
128
57
  }
129
58
  function globalCacheDir(env = process.env) {
130
59
  const xdg = env.XDG_CACHE_HOME;
@@ -133,152 +62,51 @@ function globalCacheDir(env = process.env) {
133
62
  return path.join(home, ".cache", "qavor");
134
63
  }
135
64
 
136
- // src/git/git.ts
137
- import { execa } from "execa";
65
+ // src/manifest/loader.ts
138
66
  import fs2 from "fs/promises";
139
67
  import path2 from "path";
140
- import simpleGit from "simple-git";
141
- async function runGit(args, opts) {
142
- let child;
143
- try {
144
- child = execa("git", args, {
145
- cwd: opts.cwd,
146
- env: opts.env ? { ...process.env, ...opts.env } : process.env,
147
- ...opts.signal ? { cancelSignal: opts.signal } : {},
148
- stdout: "pipe",
149
- stderr: "pipe"
150
- });
151
- const res = await child;
152
- return typeof res.stdout === "string" ? res.stdout : "";
153
- } catch (err) {
154
- const ee = err;
155
- const stderr = typeof ee.stderr === "string" ? ee.stderr : "";
156
- const stdout = typeof ee.stdout === "string" ? ee.stdout : "";
157
- const code2 = ee.exitCode ?? -1;
158
- const tail = stderr.trim() || stdout.trim() || ee.shortMessage || ee.message;
159
- throw new RuntimeFailure(`git ${args.join(" ")} (exit ${code2}) in ${opts.cwd}
160
- ${tail}`);
161
- }
162
- }
163
- async function isGitRepo(dir) {
164
- if (!await isDirectory(dir)) return false;
165
- try {
166
- await fs2.access(path2.join(dir, ".git"));
167
- return true;
168
- } catch {
169
- try {
170
- const out = await runGit(["rev-parse", "--is-inside-work-tree"], { cwd: dir });
171
- return out.trim() === "true";
172
- } catch {
173
- return false;
174
- }
175
- }
176
- }
177
- async function readRepoStatus(dir) {
178
- const git = simpleGit({ baseDir: dir });
179
- let branch = null;
180
- try {
181
- const summary = await git.branch();
182
- branch = summary.current || null;
183
- } catch {
184
- branch = null;
185
- }
186
- let ahead = 0;
187
- let behind = 0;
188
- try {
189
- const counts = await runGit(["rev-list", "--left-right", "--count", "@{u}...HEAD"], { cwd: dir });
190
- const [b, a] = counts.trim().split(/\s+/).map((n) => Number.parseInt(n, 10));
191
- behind = Number.isFinite(b) ? b ?? 0 : 0;
192
- ahead = Number.isFinite(a) ? a ?? 0 : 0;
193
- } catch {
194
- }
195
- let dirtyCount = 0;
196
- try {
197
- const status = await git.status();
198
- dirtyCount = status.files.length;
199
- } catch {
200
- }
201
- let lastCommit = null;
202
- let lastCommitSubject = null;
203
- try {
204
- const log = await git.log({ maxCount: 1 });
205
- if (log.latest) {
206
- lastCommit = log.latest.hash.slice(0, 7);
207
- lastCommitSubject = log.latest.message;
208
- }
209
- } catch {
68
+ import { parseAllDocuments } from "yaml";
69
+
70
+ // src/util/exit-codes.ts
71
+ var ExitCode = {
72
+ Ok: 0,
73
+ UserError: 1,
74
+ ManifestError: 2,
75
+ RuntimeError: 3
76
+ };
77
+ var QavorError = class extends Error {
78
+ exitCode;
79
+ constructor(message, exitCode = ExitCode.RuntimeError) {
80
+ super(message);
81
+ this.name = "QavorError";
82
+ this.exitCode = exitCode;
210
83
  }
211
- return { branch, ahead, behind, dirtyCount, lastCommit, lastCommitSubject };
212
- }
213
- async function gitClone(opts) {
214
- const args = ["clone"];
215
- if (opts.branch && !opts.commit) args.push("--branch", opts.branch);
216
- else if (opts.tag && !opts.commit) args.push("--branch", opts.tag);
217
- if (opts.shallow) args.push("--depth", "1");
218
- if (opts.submodules) args.push("--recurse-submodules");
219
- args.push("--", opts.url, opts.dest);
220
- await fs2.mkdir(path2.dirname(opts.dest), { recursive: true });
221
- const runOpts = { cwd: path2.dirname(opts.dest) };
222
- if (opts.signal) runOpts.signal = opts.signal;
223
- await runGit(args, runOpts);
224
- if (opts.commit) {
225
- const checkoutOpts = { cwd: opts.dest };
226
- if (opts.signal) checkoutOpts.signal = opts.signal;
227
- await runGit(["checkout", opts.commit], checkoutOpts);
84
+ };
85
+ var UserError = class extends QavorError {
86
+ constructor(message) {
87
+ super(message, ExitCode.UserError);
88
+ this.name = "UserError";
228
89
  }
229
- }
230
- async function gitFetch(dir, signal) {
231
- const opts = { cwd: dir };
232
- if (signal) opts.signal = signal;
233
- await runGit(["fetch", "--prune"], opts);
234
- }
235
- async function gitPullFastForward(dir, signal) {
236
- const opts = { cwd: dir };
237
- if (signal) opts.signal = signal;
238
- await runGit(["pull", "--ff-only"], opts);
239
- }
240
- async function gitCommit(dir, message, opts = {}) {
241
- const status = await runGit(["status", "--porcelain"], { cwd: dir });
242
- if (status.trim().length === 0 && !opts.allowEmpty) {
243
- return { committed: false };
90
+ };
91
+ var ManifestError = class extends QavorError {
92
+ constructor(message) {
93
+ super(message, ExitCode.ManifestError);
94
+ this.name = "ManifestError";
244
95
  }
245
- const addOpts = { cwd: dir };
246
- if (opts.signal) addOpts.signal = opts.signal;
247
- await runGit(["add", "-A"], addOpts);
248
- const args = ["commit", "-m", message];
249
- if (opts.allowEmpty) args.push("--allow-empty");
250
- const commitOpts = { cwd: dir };
251
- if (opts.signal) commitOpts.signal = opts.signal;
252
- await runGit(args, commitOpts);
253
- return { committed: true };
254
- }
255
- async function gitPush(dir, signal) {
256
- const opts = { cwd: dir };
257
- if (signal) opts.signal = signal;
258
- await runGit(["push"], opts);
259
- }
260
- function deriveCloneUrl(input) {
261
- if (input.explicitUrl) return input.explicitUrl;
262
- if (!input.rootUrl) {
263
- throw new RuntimeFailure(
264
- `Cannot derive clone URL for '${input.name}': project manifest has no git.root_url and no explicit url is set.`
265
- );
96
+ };
97
+ var RuntimeFailure = class extends QavorError {
98
+ constructor(message) {
99
+ super(message, ExitCode.RuntimeError);
100
+ this.name = "RuntimeFailure";
266
101
  }
267
- const prefix = input.repoPrefix ?? "";
268
- const base = input.rootUrl.endsWith("/") ? input.rootUrl.slice(0, -1) : input.rootUrl;
269
- const fullName = `${prefix}${input.name}`;
270
- return `${base}/${fullName}.git`;
271
- }
102
+ };
272
103
 
273
104
  // src/manifest/loader.ts
274
- import fs3 from "fs/promises";
275
- import path3 from "path";
276
- import { parseAllDocuments } from "yaml";
277
105
  async function loadManifestFile(filePath, opts = {}) {
278
- const absFile = path3.resolve(filePath);
106
+ const absFile = path2.resolve(filePath);
279
107
  let source;
280
108
  try {
281
- source = await fs3.readFile(absFile, "utf8");
109
+ source = await fs2.readFile(absFile, "utf8");
282
110
  } catch (err) {
283
111
  if (err.code === "ENOENT") {
284
112
  throw new ManifestError(`Manifest file not found: ${absFile}`);
@@ -293,6 +121,7 @@ async function loadManifestFile(filePath, opts = {}) {
293
121
  for (const doc of docs) {
294
122
  if (doc.errors.length && opts.throwOnParseError !== false) {
295
123
  const e = doc.errors[0];
124
+ if (!e) continue;
296
125
  const pos = errorPosition(absFile, source, e);
297
126
  throw new ManifestError(
298
127
  `${pos.file}:${pos.line}:${pos.column}: YAML parse error: ${e.message}`
@@ -388,11 +217,7 @@ var qavor_defs_schema_default = {
388
217
  },
389
218
  envScalar: {
390
219
  description: "Scalar value usable on the right-hand side of an env entry. Strings support ${VAR} and ${secret:NAME} interpolation.",
391
- oneOf: [
392
- { type: "string" },
393
- { type: "number" },
394
- { type: "boolean" }
395
- ]
220
+ oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }]
396
221
  },
397
222
  envSpec: {
398
223
  description: "Long-form env entry. Use when you need typing, validation, default vs override, secret marking, or documentation.",
@@ -402,7 +227,10 @@ var qavor_defs_schema_default = {
402
227
  value: { $ref: "#/$defs/envScalar" },
403
228
  default: { $ref: "#/$defs/envScalar" },
404
229
  required: { type: "boolean", default: false },
405
- type: { type: "string", enum: ["string", "int", "number", "bool", "url", "duration"] },
230
+ type: {
231
+ type: "string",
232
+ enum: ["string", "int", "number", "bool", "url", "duration"]
233
+ },
406
234
  pattern: { type: "string", format: "regex" },
407
235
  secret: { type: "boolean", default: false },
408
236
  description: { type: "string" }
@@ -413,10 +241,7 @@ var qavor_defs_schema_default = {
413
241
  type: "object",
414
242
  patternProperties: {
415
243
  "^[A-Z_][A-Z0-9_]*$": {
416
- oneOf: [
417
- { $ref: "#/$defs/envScalar" },
418
- { $ref: "#/$defs/envSpec" }
419
- ]
244
+ oneOf: [{ $ref: "#/$defs/envScalar" }, { $ref: "#/$defs/envSpec" }]
420
245
  }
421
246
  },
422
247
  additionalProperties: false
@@ -448,10 +273,19 @@ var qavor_defs_schema_default = {
448
273
  additionalProperties: false,
449
274
  required: ["cmd"],
450
275
  properties: {
451
- cmd: { type: "string", description: "Shell command. Multiline strings are treated as a script." },
452
- cwd: { type: "string", description: "Working directory relative to the manifest file." },
276
+ cmd: {
277
+ type: "string",
278
+ description: "Shell command. Multiline strings are treated as a script."
279
+ },
280
+ cwd: {
281
+ type: "string",
282
+ description: "Working directory relative to the manifest file."
283
+ },
453
284
  env: { $ref: "#/$defs/envMap" },
454
- shell: { type: "string", description: "Override shell. Defaults to `/bin/sh -c` (POSIX) or `cmd /C` on Windows." }
285
+ shell: {
286
+ type: "string",
287
+ description: "Override shell. Defaults to `/bin/sh -c` (POSIX) or `cmd /C` on Windows."
288
+ }
455
289
  }
456
290
  },
457
291
  runtimeBackend: {
@@ -490,7 +324,10 @@ var qavor_defs_schema_default = {
490
324
  type: "object",
491
325
  additionalProperties: false,
492
326
  properties: {
493
- service: { type: "string", description: "Service reference. `<service>` for same-workspace, `<repo>:<service>` permitted." },
327
+ service: {
328
+ type: "string",
329
+ description: "Service reference. `<service>` for same-workspace, `<repo>:<service>` permitted."
330
+ },
494
331
  stateful: { $ref: "#/$defs/name" },
495
332
  group: { $ref: "#/$defs/name" },
496
333
  optional: { type: "boolean", default: false },
@@ -541,22 +378,37 @@ var qavor_defs_schema_default = {
541
378
  }
542
379
  };
543
380
 
544
- // src/schema/qavor.workspaces.schema.json
545
- var qavor_workspaces_schema_default = {
381
+ // src/schema/qavor.profile.schema.json
382
+ var qavor_profile_schema_default = {
546
383
  $schema: "https://json-schema.org/draft/2020-12/schema",
547
- $id: "https://qavor.dev/schemas/qavor.workspaces.schema.json",
548
- title: "qavor workspaces manifest",
549
- description: "Workspace pointer file. Lives at the root of the workspace directory as `qavor.yaml` and is created automatically by `qavor init`. Its only job is to point at the project repo whose `kind: project` manifest enumerates the rest of the workspace.",
384
+ $id: "https://qavor.dev/schemas/qavor.profile.schema.json",
385
+ title: "qavor profile manifest",
386
+ description: "Reusable runtime + env bundle. Referenced by services and stateful manifests via the `profiles:` list. Profiles can themselves reference other profiles; resolution flattens the chain in declaration order with later entries winning. A profile's runtime/env layer below the referencing manifest's own runtime/env.",
550
387
  type: "object",
551
388
  additionalProperties: false,
552
- required: ["kind", "root_project_path"],
389
+ required: ["kind", "name"],
553
390
  properties: {
554
- kind: { const: "workspaces" },
555
- schemaVersion: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion" },
556
- root_project_path: {
391
+ kind: { const: "profile" },
392
+ schemaVersion: {
393
+ $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion"
394
+ },
395
+ name: {
396
+ $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/name",
397
+ description: "Profile identifier. Referenced from `profiles:` lists and from CLI flags."
398
+ },
399
+ description: { type: "string" },
400
+ profiles: {
401
+ type: "array",
402
+ items: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/profileRef" },
403
+ uniqueItems: true,
404
+ description: "Other profiles this one extends. Resolved in declaration order before this profile's own values are applied."
405
+ },
406
+ runtime: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/runtimeBlock" },
407
+ mode: {
557
408
  type: "string",
558
- description: "Workspace-relative path to the directory containing the project repo's `qavor.yaml` (kind: project)."
559
- }
409
+ enum: ["native", "docker", "docker-compose"]
410
+ },
411
+ env: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/envBlock" }
560
412
  }
561
413
  };
562
414
 
@@ -571,7 +423,9 @@ var qavor_project_schema_default = {
571
423
  required: ["kind", "name", "repositories"],
572
424
  properties: {
573
425
  kind: { const: "project" },
574
- schemaVersion: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion" },
426
+ schemaVersion: {
427
+ $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion"
428
+ },
575
429
  name: {
576
430
  $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/name",
577
431
  description: "Human-readable workspace name. Used as the compose project namespace and the on-disk workspace identifier."
@@ -690,7 +544,9 @@ var qavor_repo_schema_default = {
690
544
  required: ["kind", "name"],
691
545
  properties: {
692
546
  kind: { const: "repo" },
693
- schemaVersion: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion" },
547
+ schemaVersion: {
548
+ $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion"
549
+ },
694
550
  name: {
695
551
  $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/name",
696
552
  description: "Repository identifier. Must match the `name` used in the project manifest's `repositories` list."
@@ -717,7 +573,9 @@ var qavor_service_schema_default = {
717
573
  required: ["kind", "name"],
718
574
  properties: {
719
575
  kind: { const: "service" },
720
- schemaVersion: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion" },
576
+ schemaVersion: {
577
+ $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion"
578
+ },
721
579
  name: {
722
580
  $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/name",
723
581
  description: "Service identifier. Must be unique within the workspace; cross-repo references use this name."
@@ -765,7 +623,9 @@ var qavor_stateful_schema_default = {
765
623
  required: ["kind", "name"],
766
624
  properties: {
767
625
  kind: { const: "stateful" },
768
- schemaVersion: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion" },
626
+ schemaVersion: {
627
+ $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion"
628
+ },
769
629
  name: {
770
630
  $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/name",
771
631
  description: "Stateful service identifier. Must be unique within the workspace."
@@ -804,35 +664,24 @@ var qavor_stateful_schema_default = {
804
664
  }
805
665
  };
806
666
 
807
- // src/schema/qavor.profile.schema.json
808
- var qavor_profile_schema_default = {
667
+ // src/schema/qavor.workspaces.schema.json
668
+ var qavor_workspaces_schema_default = {
809
669
  $schema: "https://json-schema.org/draft/2020-12/schema",
810
- $id: "https://qavor.dev/schemas/qavor.profile.schema.json",
811
- title: "qavor profile manifest",
812
- description: "Reusable runtime + env bundle. Referenced by services and stateful manifests via the `profiles:` list. Profiles can themselves reference other profiles; resolution flattens the chain in declaration order with later entries winning. A profile's runtime/env layer below the referencing manifest's own runtime/env.",
670
+ $id: "https://qavor.dev/schemas/qavor.workspaces.schema.json",
671
+ title: "qavor workspaces manifest",
672
+ description: "Workspace pointer file. Lives at the root of the workspace directory as `qavor.yaml` and is created automatically by `qavor init`. Its only job is to point at the project repo whose `kind: project` manifest enumerates the rest of the workspace.",
813
673
  type: "object",
814
674
  additionalProperties: false,
815
- required: ["kind", "name"],
675
+ required: ["kind", "root_project_path"],
816
676
  properties: {
817
- kind: { const: "profile" },
818
- schemaVersion: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion" },
819
- name: {
820
- $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/name",
821
- description: "Profile identifier. Referenced from `profiles:` lists and from CLI flags."
822
- },
823
- description: { type: "string" },
824
- profiles: {
825
- type: "array",
826
- items: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/profileRef" },
827
- uniqueItems: true,
828
- description: "Other profiles this one extends. Resolved in declaration order before this profile's own values are applied."
677
+ kind: { const: "workspaces" },
678
+ schemaVersion: {
679
+ $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/schemaVersion"
829
680
  },
830
- runtime: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/runtimeBlock" },
831
- mode: {
681
+ root_project_path: {
832
682
  type: "string",
833
- enum: ["native", "docker", "docker-compose"]
834
- },
835
- env: { $ref: "https://qavor.dev/schemas/qavor.defs.schema.json#/$defs/envBlock" }
683
+ description: "Workspace-relative path to the directory containing the project repo's `qavor.yaml` (kind: project)."
684
+ }
836
685
  }
837
686
  };
838
687
 
@@ -950,183 +799,222 @@ function formatIssue(i) {
950
799
  return `${i.file}:${i.line}:${i.column} [${i.kind}] ${i.path}: ${i.message}`;
951
800
  }
952
801
 
953
- // src/workspace/paths.ts
954
- import path4 from "path";
955
- function workspacePaths(root) {
956
- const abs = path4.resolve(root);
957
- const stateRoot = path4.join(abs, ".qavor");
958
- return {
959
- root: abs,
960
- workspacesFile: path4.join(abs, "qavor.yaml"),
961
- stateRoot,
962
- stateDir: path4.join(stateRoot, "state"),
963
- logsDir: path4.join(stateRoot, "logs"),
964
- composeDir: path4.join(stateRoot, "compose"),
965
- cacheDir: path4.join(stateRoot, "cache"),
966
- workspaceMetaFile: path4.join(stateRoot, "workspace.json"),
967
- stateGitignore: path4.join(stateRoot, ".gitignore")
968
- };
969
- }
970
-
971
- // src/workspace/init.ts
972
- var URL_RE = /^(?:git@[^:]+:|https?:\/\/|git:\/\/|ssh:\/\/|file:\/\/)/;
973
- function looksLikeGitUrl(s) {
974
- return URL_RE.test(s);
975
- }
976
- function projectRepoNameFromUrl(url) {
977
- const cleaned = url.replace(/[?#].*$/, "");
978
- const last = cleaned.split("/").pop() ?? cleaned.split(":").pop() ?? "project";
979
- return last.replace(/\.git$/, "");
980
- }
981
- async function initWorkspace(opts) {
982
- const workspaceRoot = path5.resolve(opts.into ?? process.cwd());
983
- await ensureDir(workspaceRoot);
984
- const paths = workspacePaths(workspaceRoot);
985
- let projectRepoPath;
986
- let cloned = false;
987
- if (looksLikeGitUrl(opts.source)) {
988
- const repoName = projectRepoNameFromUrl(opts.source);
989
- const target = path5.join(workspaceRoot, `${repoName}.git`);
990
- if (await isDirectory(target)) {
991
- if (!await isGitRepo(target)) {
992
- throw new UserError(
993
- `Cannot reuse ${target}: directory exists but is not a git repo. Move it aside and re-run.`
994
- );
995
- }
996
- opts.logger.info({ target }, "reusing existing project repo clone");
997
- projectRepoPath = target;
998
- } else {
999
- const cacheDir = path5.join(globalCacheDir(), "projects", urlHash(opts.source));
1000
- await ensureDir(path5.dirname(cacheDir));
1001
- opts.logger.info({ url: opts.source, target }, "cloning project repo");
1002
- await gitClone({ url: opts.source, dest: target });
1003
- try {
1004
- await ensureDir(cacheDir);
1005
- await fs4.writeFile(
1006
- path5.join(cacheDir, "source.json"),
1007
- JSON.stringify({ url: opts.source, cloned_to: target, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
1008
- );
1009
- } catch {
1010
- }
1011
- projectRepoPath = target;
1012
- cloned = true;
1013
- }
1014
- } else {
1015
- const localPath = path5.resolve(opts.source);
1016
- if (!await isDirectory(localPath)) {
1017
- throw new UserError(`Project repo source does not exist or is not a directory: ${localPath}`);
1018
- }
1019
- projectRepoPath = localPath;
802
+ // src/manifest/discovery.ts
803
+ var MAX_DEPTH = 4;
804
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
805
+ ".git",
806
+ ".qavor",
807
+ "node_modules",
808
+ ".venv",
809
+ "venv",
810
+ "__pycache__",
811
+ "dist",
812
+ "build",
813
+ "target",
814
+ ".next",
815
+ ".svelte-kit",
816
+ ".cache"
817
+ ]);
818
+ async function discoverManifestFiles(repoRoot) {
819
+ const abs = path3.resolve(repoRoot);
820
+ const found = /* @__PURE__ */ new Set();
821
+ if (!await isDirectory(abs)) return [];
822
+ const rootFile = path3.join(abs, "qavor.yaml");
823
+ try {
824
+ await fs3.access(rootFile);
825
+ found.add(rootFile);
826
+ } catch {
1020
827
  }
1021
- const projectManifestFile = path5.join(projectRepoPath, "qavor.yaml");
1022
- const docs = await loadManifestFile(projectManifestFile);
1023
- const projectDoc = docs.find((d) => d.kind === "project");
1024
- if (!projectDoc) {
1025
- throw new ManifestError(
1026
- `Project repo at ${projectRepoPath} is missing a \`kind: project\` document in qavor.yaml.`
1027
- );
828
+ const qavorDir = path3.join(abs, "qavor");
829
+ if (await isDirectory(qavorDir)) {
830
+ for await (const f of walk(qavorDir, qavorDir, 0)) found.add(f);
1028
831
  }
1029
- const result = validateDocument(projectDoc);
1030
- if (!result.ok) {
1031
- const msg = result.issues.map((i) => ` ${i.file}:${i.line}:${i.column} ${i.path}: ${i.message}`).join("\n");
1032
- throw new ManifestError(`Invalid project manifest:
1033
- ${msg}`);
832
+ for await (const f of walk(abs, abs, 0)) {
833
+ found.add(f);
1034
834
  }
1035
- const project = projectDoc.data;
1036
- await ensureDir(paths.stateRoot);
1037
- await ensureDir(paths.stateDir);
1038
- await ensureDir(paths.logsDir);
1039
- await ensureDir(paths.composeDir);
1040
- await ensureDir(paths.cacheDir);
1041
- await fs4.writeFile(
1042
- paths.stateGitignore,
1043
- [
1044
- "# qavor state directory \u2014 all files are generated. Do not commit.",
1045
- "*",
1046
- "!.gitignore",
1047
- ""
1048
- ].join("\n")
1049
- );
1050
- const relProjectPath = "./" + path5.relative(workspaceRoot, projectRepoPath).split(path5.sep).join("/");
1051
- const workspacesYaml = renderWorkspacesYaml(relProjectPath);
1052
- await fs4.writeFile(paths.workspacesFile, workspacesYaml, "utf8");
1053
- const manifestHash = createHash2("sha256").update(await fs4.readFile(projectManifestFile)).digest("hex");
1054
- await writeJsonFile(paths.workspaceMetaFile, {
1055
- project_name: project.name,
1056
- project_repo_path: projectRepoPath,
1057
- manifest_hash: manifestHash,
1058
- initialized_at: (/* @__PURE__ */ new Date()).toISOString()
1059
- });
1060
- return { paths, projectRepoPath, project, cloned };
1061
- }
1062
- function urlHash(url) {
1063
- return createHash2("sha256").update(url).digest("hex").slice(0, 16);
1064
- }
1065
- function renderWorkspacesYaml(relProjectPath) {
1066
- return [
1067
- "# Generated by `qavor init`. Points at the project repo whose",
1068
- "# `kind: project` manifest enumerates the rest of the workspace.",
1069
- "kind: workspaces",
1070
- `root_project_path: ${relProjectPath}`,
1071
- ""
1072
- ].join("\n");
1073
- }
1074
-
1075
- // src/cli/options.ts
1076
- function rootOptions(cmd) {
1077
- const opts = cmd.opts();
1078
- return {
1079
- json: Boolean(opts.json),
1080
- verbose: Boolean(opts.verbose),
1081
- jobs: typeof opts.jobs === "string" ? Number.parseInt(opts.jobs, 10) : void 0,
1082
- config: typeof opts.config === "string" ? opts.config : void 0
1083
- };
835
+ return [...found].sort();
1084
836
  }
1085
- function inheritRootOptions(cmd) {
1086
- let current = cmd;
1087
- while (current && current.parent) current = current.parent;
1088
- if (!current) return { json: false, verbose: false, jobs: void 0, config: void 0 };
1089
- return rootOptions(current);
837
+ async function* walk(rootBase, current, depth) {
838
+ if (depth > MAX_DEPTH) return;
839
+ let entries;
840
+ try {
841
+ entries = await fs3.readdir(current, { withFileTypes: true });
842
+ } catch {
843
+ return;
844
+ }
845
+ for (const entry of entries) {
846
+ const full = path3.join(current, entry.name);
847
+ if (entry.isDirectory()) {
848
+ if (SKIP_DIRS.has(entry.name)) continue;
849
+ if (depth === 0 && entry.name === "qavor") continue;
850
+ yield* walk(rootBase, full, depth + 1);
851
+ } else if (entry.isFile() && entry.name === "qavor.yaml") {
852
+ if (depth === 0 && current === rootBase) continue;
853
+ yield full;
854
+ }
855
+ }
1090
856
  }
1091
-
1092
- // src/cli/commands/init.ts
1093
- function registerInit(program) {
1094
- program.command("init").description("Bootstrap a workspace from a project repo (local path or git URL).").argument("<source>", "Local path to a project repo, or a git URL.").option("--into <dir>", "Workspace root directory. Defaults to the current directory.").action(async (source, opts, cmd) => {
1095
- const root = inheritRootOptions(cmd);
1096
- const logger = getLogger();
1097
- const initOpts = { source, logger };
1098
- if (opts.into) initOpts.into = opts.into;
1099
- const result = await initWorkspace(initOpts);
1100
- if (root.json) {
1101
- emitJson({
1102
- ok: true,
1103
- workspace: result.paths.root,
1104
- project_name: result.project.name,
1105
- project_repo_path: result.projectRepoPath,
1106
- cloned_project: result.cloned,
1107
- repositories: result.project.repositories.length
857
+ async function buildWorkspaceRegistry(opts) {
858
+ const issues = [];
859
+ const all = [];
860
+ const reposList = [];
861
+ for (const [name, dir] of opts.repos) reposList.push({ name, dir });
862
+ await pMap(
863
+ reposList,
864
+ async ({ name: repoName, dir }) => {
865
+ const files = await discoverManifestFiles(dir);
866
+ for (const file of files) {
867
+ let docs;
868
+ try {
869
+ docs = await loadManifestFile(file);
870
+ } catch (err) {
871
+ issues.push({
872
+ file,
873
+ line: 1,
874
+ column: 1,
875
+ kind: "unknown",
876
+ path: "",
877
+ message: err instanceof Error ? err.message : String(err)
878
+ });
879
+ continue;
880
+ }
881
+ for (const doc of docs) {
882
+ if (!isKnownKind(doc.kind)) {
883
+ const pos = doc.position("/kind");
884
+ issues.push({
885
+ file: pos.file,
886
+ line: pos.line,
887
+ column: pos.column,
888
+ kind: String(doc.kind ?? "unknown"),
889
+ path: "/kind",
890
+ message: `Unknown or missing kind in this document`
891
+ });
892
+ continue;
893
+ }
894
+ const result = validateDocument(doc);
895
+ if (!result.ok) {
896
+ issues.push(...result.issues);
897
+ continue;
898
+ }
899
+ const data = doc.data;
900
+ all.push({
901
+ kind: doc.kind,
902
+ name: typeof data.name === "string" ? data.name : "",
903
+ file: doc.file,
904
+ docIndex: doc.docIndex,
905
+ dir: path3.dirname(doc.file),
906
+ repo: repoName,
907
+ data: doc.data,
908
+ position: doc.position
909
+ });
910
+ }
911
+ }
912
+ },
913
+ { concurrency: opts.concurrency ?? 8 }
914
+ );
915
+ const byName = /* @__PURE__ */ new Map();
916
+ for (const entry of all) {
917
+ if (!entry.name) continue;
918
+ if (entry.kind === "workspaces" || entry.kind === "project") continue;
919
+ const key = entry.name;
920
+ const existing = byName.get(key);
921
+ if (existing && existing.kind === entry.kind) {
922
+ const pos = entry.position("/name");
923
+ issues.push({
924
+ file: pos.file,
925
+ line: pos.line,
926
+ column: pos.column,
927
+ kind: entry.kind,
928
+ path: "/name",
929
+ message: `Duplicate ${entry.kind} name '${entry.name}'. Already declared at ${existing.file}.`
1108
930
  });
1109
- } else {
1110
- emit(`Workspace initialized at ${result.paths.root}`);
1111
- emit(` project: ${result.project.name}`);
1112
- emit(` project repo: ${path6.relative(result.paths.root, result.projectRepoPath)}`);
1113
- emit(` repositories declared: ${result.project.repositories.length}`);
1114
- emit(` next: qavor clone`);
931
+ continue;
1115
932
  }
1116
- });
933
+ if (!existing) byName.set(key, entry);
934
+ }
935
+ return { byName, entries: all, issues };
1117
936
  }
1118
937
 
1119
- // src/cli/commands/workspace.ts
1120
- import path8 from "path";
1121
- import fs6 from "fs/promises";
938
+ // src/util/concurrency.ts
939
+ import os from "os";
940
+ import pLimit from "p-limit";
941
+ function resolveJobs(override) {
942
+ if (typeof override === "number" && Number.isFinite(override) && override >= 1) {
943
+ return Math.floor(override);
944
+ }
945
+ const avail = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
946
+ return Math.max(1, avail);
947
+ }
948
+
949
+ // src/util/logger.ts
950
+ import pino from "pino";
951
+ var rootLogger = null;
952
+ function configureLogger(opts) {
953
+ const level = opts.verbose ? "debug" : "info";
954
+ const stderrIsTty = Boolean(process.stderr.isTTY);
955
+ if (opts.json || !stderrIsTty) {
956
+ rootLogger = pino({ level }, pino.destination({ fd: 2, sync: true }));
957
+ } else {
958
+ rootLogger = pino({
959
+ level,
960
+ transport: {
961
+ target: "pino-pretty",
962
+ options: {
963
+ destination: 2,
964
+ colorize: stderrIsTty,
965
+ translateTime: false,
966
+ ignore: "pid,hostname,time",
967
+ messageFormat: "{msg}",
968
+ singleLine: false,
969
+ sync: true
970
+ }
971
+ }
972
+ });
973
+ }
974
+ return rootLogger;
975
+ }
976
+ function getLogger() {
977
+ if (!rootLogger) {
978
+ rootLogger = pino({ level: "info" });
979
+ }
980
+ return rootLogger;
981
+ }
982
+ function emit(text) {
983
+ process.stdout.write(`${text}
984
+ `);
985
+ }
986
+ function emitJson(payload) {
987
+ process.stdout.write(`${JSON.stringify(payload)}
988
+ `);
989
+ }
990
+
991
+ // src/workspace/locate.ts
992
+ import fs4 from "fs/promises";
993
+ import path5 from "path";
994
+
995
+ // src/workspace/paths.ts
996
+ import path4 from "path";
997
+ function workspacePaths(root) {
998
+ const abs = path4.resolve(root);
999
+ const stateRoot = path4.join(abs, ".qavor");
1000
+ return {
1001
+ root: abs,
1002
+ workspacesFile: path4.join(abs, "qavor.yaml"),
1003
+ stateRoot,
1004
+ stateDir: path4.join(stateRoot, "state"),
1005
+ logsDir: path4.join(stateRoot, "logs"),
1006
+ composeDir: path4.join(stateRoot, "compose"),
1007
+ cacheDir: path4.join(stateRoot, "cache"),
1008
+ workspaceMetaFile: path4.join(stateRoot, "workspace.json"),
1009
+ stateGitignore: path4.join(stateRoot, ".gitignore")
1010
+ };
1011
+ }
1122
1012
 
1123
1013
  // src/workspace/locate.ts
1124
- import fs5 from "fs/promises";
1125
- import path7 from "path";
1126
1014
  async function findWorkspaceRoot(start) {
1127
- let cur = path7.resolve(start);
1015
+ let cur = path5.resolve(start);
1128
1016
  for (let i = 0; i < 64; i++) {
1129
- const candidate = path7.join(cur, "qavor.yaml");
1017
+ const candidate = path5.join(cur, "qavor.yaml");
1130
1018
  if (await isFile(candidate)) {
1131
1019
  try {
1132
1020
  const docs = await loadManifestFile(candidate, { throwOnParseError: false });
@@ -1134,7 +1022,7 @@ async function findWorkspaceRoot(start) {
1134
1022
  } catch {
1135
1023
  }
1136
1024
  }
1137
- const parent = path7.dirname(cur);
1025
+ const parent = path5.dirname(cur);
1138
1026
  if (parent === cur) return null;
1139
1027
  cur = parent;
1140
1028
  }
@@ -1151,7 +1039,9 @@ async function resolveWorkspace(start = process.cwd()) {
1151
1039
  const docs = await loadManifestFile(paths.workspacesFile);
1152
1040
  const workspaceDoc = docs.find((d) => d.kind === "workspaces");
1153
1041
  if (!workspaceDoc) {
1154
- throw new UserError(`Workspace pointer at ${paths.workspacesFile} has no \`kind: workspaces\` document.`);
1042
+ throw new UserError(
1043
+ `Workspace pointer at ${paths.workspacesFile} has no \`kind: workspaces\` document.`
1044
+ );
1155
1045
  }
1156
1046
  const rootProjectPath = workspaceDoc.data.root_project_path;
1157
1047
  if (typeof rootProjectPath !== "string" || rootProjectPath.length === 0) {
@@ -1159,8 +1049,8 @@ async function resolveWorkspace(start = process.cwd()) {
1159
1049
  `Workspace pointer at ${paths.workspacesFile} is missing \`root_project_path\`.`
1160
1050
  );
1161
1051
  }
1162
- const projectRepoPath = path7.isAbsolute(rootProjectPath) ? rootProjectPath : path7.resolve(paths.root, rootProjectPath);
1163
- const projectManifestFile = path7.join(projectRepoPath, "qavor.yaml");
1052
+ const projectRepoPath = path5.isAbsolute(rootProjectPath) ? rootProjectPath : path5.resolve(paths.root, rootProjectPath);
1053
+ const projectManifestFile = path5.join(projectRepoPath, "qavor.yaml");
1164
1054
  return { paths, projectRepoPath, projectManifestFile };
1165
1055
  }
1166
1056
  async function readProjectManifest(projectManifestFile) {
@@ -1172,139 +1062,149 @@ async function readProjectManifest(projectManifestFile) {
1172
1062
  return { data: project.data };
1173
1063
  }
1174
1064
 
1175
- // src/cli/commands/workspace.ts
1176
- function registerWorkspace(program) {
1177
- const ws = program.command("workspace").description("Workspace operations.");
1178
- ws.command("info").description("Show information about the workspace at or above the cwd.").action(async (_opts, cmd) => {
1179
- const root = inheritRootOptions(cmd);
1180
- const resolved = await resolveWorkspace();
1181
- const project = await readProjectManifest(resolved.projectManifestFile);
1182
- let meta = {};
1183
- try {
1184
- meta = await readJsonFile(resolved.paths.workspaceMetaFile);
1185
- } catch {
1186
- }
1187
- const info = {
1188
- workspace_root: resolved.paths.root,
1189
- workspaces_file: resolved.paths.workspacesFile,
1190
- project_repo_path: resolved.projectRepoPath,
1191
- project_manifest_file: resolved.projectManifestFile,
1192
- project_name: typeof project.data.name === "string" ? project.data.name : null,
1193
- state_dir: resolved.paths.stateRoot,
1194
- meta
1195
- };
1196
- if (root.json) {
1197
- emitJson(info);
1198
- return;
1199
- }
1200
- emit(`Workspace root: ${info.workspace_root}`);
1201
- emit(`Workspaces manifest: ${path8.relative(info.workspace_root, info.workspaces_file)}`);
1202
- emit(`Project repo path: ${path8.relative(info.workspace_root, info.project_repo_path)}`);
1203
- emit(`Project manifest: ${path8.relative(info.workspace_root, info.project_manifest_file)}`);
1204
- emit(`Project name: ${info.project_name ?? "<unknown>"}`);
1205
- emit(`State directory: ${path8.relative(info.workspace_root, info.state_dir)}`);
1206
- if (Object.keys(meta).length > 0) {
1207
- emit("Workspace meta:");
1208
- for (const [k, v] of Object.entries(meta)) emit(` ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
1209
- }
1065
+ // src/workspace/repos.ts
1066
+ import path7 from "path";
1067
+
1068
+ // src/git/git.ts
1069
+ import fs5 from "fs/promises";
1070
+ import path6 from "path";
1071
+ import { execa } from "execa";
1072
+ import simpleGit from "simple-git";
1073
+ async function runGit(args, opts) {
1074
+ let child;
1075
+ try {
1076
+ child = execa("git", args, {
1077
+ cwd: opts.cwd,
1078
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
1079
+ ...opts.signal ? { cancelSignal: opts.signal } : {},
1080
+ stdout: "pipe",
1081
+ stderr: "pipe"
1082
+ });
1083
+ const res = await child;
1084
+ return typeof res.stdout === "string" ? res.stdout : "";
1085
+ } catch (err) {
1086
+ const ee = err;
1087
+ const stderr = typeof ee.stderr === "string" ? ee.stderr : "";
1088
+ const stdout = typeof ee.stdout === "string" ? ee.stdout : "";
1089
+ const code2 = ee.exitCode ?? -1;
1090
+ const tail = stderr.trim() || stdout.trim() || ee.shortMessage || ee.message;
1091
+ throw new RuntimeFailure(`git ${args.join(" ")} (exit ${code2}) in ${opts.cwd}
1092
+ ${tail}`);
1093
+ }
1094
+ }
1095
+ async function isGitRepo(dir) {
1096
+ if (!await isDirectory(dir)) return false;
1097
+ try {
1098
+ await fs5.access(path6.join(dir, ".git"));
1099
+ return true;
1100
+ } catch {
1210
1101
  try {
1211
- await fs6.access(resolved.paths.workspaceMetaFile);
1102
+ const out = await runGit(["rev-parse", "--is-inside-work-tree"], { cwd: dir });
1103
+ return out.trim() === "true";
1212
1104
  } catch {
1105
+ return false;
1213
1106
  }
1214
- });
1215
- }
1216
-
1217
- // src/cli/commands/validate.ts
1218
- import path9 from "path";
1219
- import fs7 from "fs/promises";
1220
- import pMap from "p-map";
1221
-
1222
- // src/util/concurrency.ts
1223
- import os from "os";
1224
- import pLimit from "p-limit";
1225
- function resolveJobs(override) {
1226
- if (typeof override === "number" && Number.isFinite(override) && override >= 1) {
1227
- return Math.floor(override);
1228
1107
  }
1229
- const avail = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
1230
- return Math.max(1, avail);
1231
1108
  }
1232
-
1233
- // src/cli/commands/validate.ts
1234
- function registerValidate(program) {
1235
- program.command("validate").description("Validate one or more qavor manifest files. Targets a file or a directory.").argument("<path>", "Path to a qavor.yaml file, a directory containing one, or a directory of multiple manifests.").action(async (target, _opts, cmd) => {
1236
- const root = inheritRootOptions(cmd);
1237
- const logger = getLogger();
1238
- const abs = path9.resolve(target);
1239
- const files = [];
1240
- if (await isFile(abs)) {
1241
- files.push(abs);
1242
- } else if (await isDirectory(abs)) {
1243
- const direct = path9.join(abs, "qavor.yaml");
1244
- if (await isFile(direct)) files.push(direct);
1245
- try {
1246
- const entries = await fs7.readdir(abs, { withFileTypes: true });
1247
- for (const e of entries) {
1248
- if (e.isDirectory()) {
1249
- const child = path9.join(abs, e.name, "qavor.yaml");
1250
- if (await isFile(child)) files.push(child);
1251
- }
1252
- if (e.isFile() && e.name === "qavor.yaml") {
1253
- }
1254
- }
1255
- } catch {
1256
- }
1257
- } else {
1258
- throw new UserError(`Path not found: ${abs}`);
1109
+ async function readRepoStatus(dir) {
1110
+ const git = simpleGit({ baseDir: dir });
1111
+ let branch = null;
1112
+ try {
1113
+ const summary = await git.branch();
1114
+ branch = summary.current || null;
1115
+ } catch {
1116
+ branch = null;
1117
+ }
1118
+ let ahead = 0;
1119
+ let behind = 0;
1120
+ try {
1121
+ const counts = await runGit(["rev-list", "--left-right", "--count", "@{u}...HEAD"], {
1122
+ cwd: dir
1123
+ });
1124
+ const [b, a] = counts.trim().split(/\s+/).map((n) => Number.parseInt(n, 10));
1125
+ behind = Number.isFinite(b) ? b ?? 0 : 0;
1126
+ ahead = Number.isFinite(a) ? a ?? 0 : 0;
1127
+ } catch {
1128
+ }
1129
+ let dirtyCount = 0;
1130
+ try {
1131
+ const status = await git.status();
1132
+ dirtyCount = status.files.length;
1133
+ } catch {
1134
+ }
1135
+ let lastCommit = null;
1136
+ let lastCommitSubject = null;
1137
+ try {
1138
+ const log = await git.log({ maxCount: 1 });
1139
+ if (log.latest) {
1140
+ lastCommit = log.latest.hash.slice(0, 7);
1141
+ lastCommitSubject = log.latest.message;
1259
1142
  }
1260
- if (files.length === 0) throw new UserError(`No qavor.yaml files found under ${abs}.`);
1261
- const jobs = resolveJobs(root.jobs);
1262
- const issues = [];
1263
- await pMap(
1264
- files,
1265
- async (file) => {
1266
- try {
1267
- const docs = await loadManifestFile(file);
1268
- for (const d of docs) {
1269
- const r = validateDocument(d);
1270
- if (!r.ok) issues.push(...r.issues);
1271
- }
1272
- } catch (err) {
1273
- issues.push({
1274
- file,
1275
- line: 1,
1276
- column: 1,
1277
- kind: "unknown",
1278
- path: "",
1279
- message: err instanceof Error ? err.message : String(err)
1280
- });
1281
- }
1282
- },
1283
- { concurrency: jobs }
1143
+ } catch {
1144
+ }
1145
+ return { branch, ahead, behind, dirtyCount, lastCommit, lastCommitSubject };
1146
+ }
1147
+ async function gitClone(opts) {
1148
+ const args = ["clone"];
1149
+ if (opts.branch && !opts.commit) args.push("--branch", opts.branch);
1150
+ else if (opts.tag && !opts.commit) args.push("--branch", opts.tag);
1151
+ if (opts.shallow) args.push("--depth", "1");
1152
+ if (opts.submodules) args.push("--recurse-submodules");
1153
+ args.push("--", opts.url, opts.dest);
1154
+ await fs5.mkdir(path6.dirname(opts.dest), { recursive: true });
1155
+ const runOpts = { cwd: path6.dirname(opts.dest) };
1156
+ if (opts.signal) runOpts.signal = opts.signal;
1157
+ await runGit(args, runOpts);
1158
+ if (opts.commit) {
1159
+ const checkoutOpts = { cwd: opts.dest };
1160
+ if (opts.signal) checkoutOpts.signal = opts.signal;
1161
+ await runGit(["checkout", opts.commit], checkoutOpts);
1162
+ }
1163
+ }
1164
+ async function gitFetch(dir, signal) {
1165
+ const opts = { cwd: dir };
1166
+ if (signal) opts.signal = signal;
1167
+ await runGit(["fetch", "--prune"], opts);
1168
+ }
1169
+ async function gitPullFastForward(dir, signal) {
1170
+ const opts = { cwd: dir };
1171
+ if (signal) opts.signal = signal;
1172
+ await runGit(["pull", "--ff-only"], opts);
1173
+ }
1174
+ async function gitCommit(dir, message, opts = {}) {
1175
+ const status = await runGit(["status", "--porcelain"], { cwd: dir });
1176
+ if (status.trim().length === 0 && !opts.allowEmpty) {
1177
+ return { committed: false };
1178
+ }
1179
+ const addOpts = { cwd: dir };
1180
+ if (opts.signal) addOpts.signal = opts.signal;
1181
+ await runGit(["add", "-A"], addOpts);
1182
+ const args = ["commit", "-m", message];
1183
+ if (opts.allowEmpty) args.push("--allow-empty");
1184
+ const commitOpts = { cwd: dir };
1185
+ if (opts.signal) commitOpts.signal = opts.signal;
1186
+ await runGit(args, commitOpts);
1187
+ return { committed: true };
1188
+ }
1189
+ async function gitPush(dir, signal) {
1190
+ const opts = { cwd: dir };
1191
+ if (signal) opts.signal = signal;
1192
+ await runGit(["push"], opts);
1193
+ }
1194
+ function deriveCloneUrl(input) {
1195
+ if (input.explicitUrl) return input.explicitUrl;
1196
+ if (!input.rootUrl) {
1197
+ throw new RuntimeFailure(
1198
+ `Cannot derive clone URL for '${input.name}': project manifest has no git.root_url and no explicit url is set.`
1284
1199
  );
1285
- if (root.json) {
1286
- emitJson({ ok: issues.length === 0, files: files.length, issues });
1287
- } else {
1288
- if (issues.length === 0) {
1289
- emit(`OK \u2014 ${files.length} file(s) validated.`);
1290
- } else {
1291
- emit(`FAILED \u2014 ${issues.length} issue(s) across ${files.length} file(s):`);
1292
- for (const i of issues) emit(` ${formatIssue(i)}`);
1293
- }
1294
- }
1295
- if (issues.length > 0) {
1296
- logger.debug({ count: issues.length }, "validation failed");
1297
- throw new ManifestError(`Validation failed with ${issues.length} issue(s).`);
1298
- }
1299
- });
1200
+ }
1201
+ const prefix = input.repoPrefix ?? "";
1202
+ const base = input.rootUrl.endsWith("/") ? input.rootUrl.slice(0, -1) : input.rootUrl;
1203
+ const fullName = `${prefix}${input.name}`;
1204
+ return `${base}/${fullName}.git`;
1300
1205
  }
1301
1206
 
1302
- // src/cli/commands/git.ts
1303
- import path11 from "path";
1304
- import pMap2 from "p-map";
1305
-
1306
1207
  // src/workspace/repos.ts
1307
- import path10 from "path";
1308
1208
  function resolveRepos(opts) {
1309
1209
  const list = opts.project.repositories;
1310
1210
  const repos = [];
@@ -1317,7 +1217,7 @@ function resolveRepos(opts) {
1317
1217
  throw new ManifestError(`Duplicate repository name in project manifest: '${name}'.`);
1318
1218
  }
1319
1219
  seen.add(name);
1320
- const dir = normalized.path ? path10.isAbsolute(normalized.path) ? normalized.path : path10.resolve(opts.workspaceRoot, normalized.path) : path10.join(opts.workspaceRoot, `${name}.git`);
1220
+ const dir = normalized.path ? path7.isAbsolute(normalized.path) ? normalized.path : path7.resolve(opts.workspaceRoot, normalized.path) : path7.join(opts.workspaceRoot, `${name}.git`);
1321
1221
  const url = deriveCloneUrl({
1322
1222
  explicitUrl: normalized.url,
1323
1223
  rootUrl: opts.project.git?.root_url,
@@ -1334,399 +1234,179 @@ function resolveRepos(opts) {
1334
1234
  shallow: normalized.shallow ?? opts.project.git?.shallow,
1335
1235
  submodules: normalized.submodules ?? opts.project.git?.submodules,
1336
1236
  optional: Boolean(normalized.optional),
1337
- isProjectRepo: path10.resolve(dir) === path10.resolve(opts.projectRepoPath)
1237
+ isProjectRepo: path7.resolve(dir) === path7.resolve(opts.projectRepoPath)
1338
1238
  });
1339
1239
  }
1340
1240
  return repos;
1341
1241
  }
1342
1242
 
1343
- // src/cli/repos.ts
1344
- function selectRepos(all, selector) {
1345
- if (!selector || selector.length === 0) return all;
1346
- const set = new Set(selector);
1347
- const out = [];
1348
- for (const r of all) {
1349
- if (set.has(r.name)) {
1350
- out.push(r);
1351
- set.delete(r.name);
1352
- }
1353
- }
1354
- if (set.size > 0) {
1355
- throw new Error(
1356
- `Unknown repo${set.size > 1 ? "s" : ""}: ${[...set].join(", ")}`
1357
- );
1358
- }
1359
- return out;
1243
+ // src/cli/options.ts
1244
+ function rootOptions(cmd) {
1245
+ const opts = cmd.opts();
1246
+ return {
1247
+ json: Boolean(opts.json),
1248
+ verbose: Boolean(opts.verbose),
1249
+ jobs: typeof opts.jobs === "string" ? Number.parseInt(opts.jobs, 10) : void 0,
1250
+ config: typeof opts.config === "string" ? opts.config : void 0
1251
+ };
1360
1252
  }
1361
- async function reposPresent(repos) {
1362
- const out = [];
1363
- for (const r of repos) {
1364
- if (await isDirectory(r.dir)) out.push(r);
1365
- }
1366
- return out;
1253
+ function inheritRootOptions(cmd) {
1254
+ let current = cmd;
1255
+ while (current?.parent) current = current.parent;
1256
+ if (!current) return { json: false, verbose: false, jobs: void 0, config: void 0 };
1257
+ return rootOptions(current);
1367
1258
  }
1368
1259
 
1369
- // src/cli/commands/git.ts
1370
- async function loadProjectRepos() {
1371
- const ws = await resolveWorkspace();
1372
- const project = await readProjectManifest(ws.projectManifestFile);
1373
- const repos = resolveRepos({
1374
- workspaceRoot: ws.paths.root,
1375
- project: project.data,
1376
- projectRepoPath: ws.projectRepoPath
1377
- });
1378
- return { workspaceRoot: ws.paths.root, repos };
1379
- }
1380
- function repoOption(c) {
1381
- return c.option("--repo <name...>", "Operate on a subset of repos by name.");
1260
+ // src/cli/commands/doctor.ts
1261
+ async function runShell(cmd, cwd) {
1262
+ try {
1263
+ const res = await execa2("/bin/sh", ["-c", cmd], { cwd, reject: false });
1264
+ return { ok: res.exitCode === 0, exitCode: res.exitCode ?? -1 };
1265
+ } catch {
1266
+ return { ok: false, exitCode: -1 };
1267
+ }
1382
1268
  }
1383
- function registerGitCommands(program) {
1384
- repoOption(
1385
- program.command("clone").description("Clone every repo enumerated in the project manifest.")
1386
- ).action(async (opts, cmd) => {
1269
+ function registerDoctor(program) {
1270
+ program.command("doctor").description(
1271
+ "Verify toolchain prerequisites, workspace paths, and per-service check_installed steps."
1272
+ ).action(async (_opts, cmd) => {
1387
1273
  const root = inheritRootOptions(cmd);
1388
1274
  const logger = getLogger();
1389
- const { workspaceRoot, repos } = await loadProjectRepos();
1390
- const selected = selectRepos(repos, opts.repo);
1391
- const jobs = resolveJobs(root.jobs);
1392
- const results = [];
1393
- await pMap2(
1394
- selected,
1395
- async (r) => {
1396
- if (r.isProjectRepo) {
1397
- results.push({ repo: r.name, status: "present", message: "project repo (already cloned)" });
1398
- return;
1399
- }
1400
- if (await isGitRepo(r.dir)) {
1401
- results.push({ repo: r.name, status: "present" });
1402
- return;
1403
- }
1404
- try {
1405
- logger.info({ repo: r.name, url: r.url, dir: r.dir }, "clone: starting");
1406
- await gitClone({
1407
- url: r.url,
1408
- dest: r.dir,
1409
- branch: r.branch,
1410
- tag: r.tag,
1411
- commit: r.commit,
1412
- shallow: r.shallow,
1413
- submodules: r.submodules
1414
- });
1415
- results.push({ repo: r.name, status: "cloned" });
1416
- } catch (err) {
1417
- if (r.optional) {
1418
- results.push({ repo: r.name, status: "skipped", message: "optional; clone failed" });
1419
- } else {
1420
- throw new RuntimeFailure(
1421
- `Clone failed for ${r.name}: ${err instanceof Error ? err.message : String(err)}`
1422
- );
1423
- }
1424
- }
1425
- },
1426
- { concurrency: jobs }
1427
- );
1428
- if (root.json) {
1429
- emitJson({ workspace: workspaceRoot, results });
1430
- return;
1431
- }
1432
- for (const r of results) {
1433
- emit(`${r.status.padEnd(8)} ${r.repo}${r.message ? " \u2014 " + r.message : ""}`);
1434
- }
1435
- });
1436
- repoOption(
1437
- program.command("sync").description("Run `git fetch && git pull --ff-only` across selected repos.")
1438
- ).action(async (opts, cmd) => {
1439
- const root = inheritRootOptions(cmd);
1440
- const { repos } = await loadProjectRepos();
1441
- const selected = await reposPresent(selectRepos(repos, opts.repo));
1442
- const jobs = resolveJobs(root.jobs);
1443
- const results = [];
1444
- await pMap2(
1445
- selected,
1446
- async (r) => {
1447
- try {
1448
- await gitFetch(r.dir);
1449
- await gitPullFastForward(r.dir);
1450
- results.push({ repo: r.name, ok: true });
1451
- } catch (err) {
1452
- results.push({ repo: r.name, ok: false, error: err instanceof Error ? err.message : String(err) });
1453
- }
1454
- },
1455
- { concurrency: jobs }
1456
- );
1457
- if (root.json) {
1458
- emitJson({ results });
1459
- return;
1275
+ const checks = [];
1276
+ try {
1277
+ const res = await execa2("git", ["--version"]);
1278
+ const version = res.stdout.trim().replace(/^git version /, "");
1279
+ const [maj, min] = version.split(".").map((s) => Number.parseInt(s, 10));
1280
+ if (Number.isFinite(maj) && Number.isFinite(min) && ((maj ?? 0) > 2 || (maj ?? 0) === 2 && (min ?? 0) >= 30)) {
1281
+ checks.push({ name: "git \u2265 2.30", status: "ok", message: version });
1282
+ } else {
1283
+ checks.push({ name: "git \u2265 2.30", status: "warn", message: `found ${version}` });
1284
+ }
1285
+ } catch {
1286
+ checks.push({
1287
+ name: "git \u2265 2.30",
1288
+ status: "fail",
1289
+ message: "git not found",
1290
+ hint: "Install git."
1291
+ });
1460
1292
  }
1461
- for (const r of results) emit(`${r.ok ? "ok " : "fail"} ${r.repo}${r.error ? " \u2014 " + r.error : ""}`);
1462
- if (results.some((r) => !r.ok)) throw new RuntimeFailure("Some repos failed to sync.");
1463
- });
1464
- repoOption(
1465
- program.command("status").description("Aggregated repo status across selected repos.")
1466
- ).action(async (opts, cmd) => {
1467
- const root = inheritRootOptions(cmd);
1468
- const { workspaceRoot, repos } = await loadProjectRepos();
1469
- const selected = await reposPresent(selectRepos(repos, opts.repo));
1470
- const jobs = resolveJobs(root.jobs);
1471
- const rows = await pMap2(
1472
- selected,
1473
- async (r) => {
1474
- const s = await readRepoStatus(r.dir);
1475
- return {
1476
- repo: r.name,
1477
- branch: s.branch,
1478
- ahead: s.ahead,
1479
- behind: s.behind,
1480
- dirty: s.dirtyCount,
1481
- last_commit: s.lastCommit,
1482
- last_commit_subject: s.lastCommitSubject
1483
- };
1484
- },
1485
- { concurrency: jobs }
1486
- );
1487
- if (root.json) {
1488
- emitJson({ workspace: workspaceRoot, repos: rows });
1489
- return;
1293
+ try {
1294
+ await execa2("docker", ["--version"]);
1295
+ checks.push({ name: "docker (optional v0)", status: "ok" });
1296
+ } catch {
1297
+ checks.push({
1298
+ name: "docker (optional v0)",
1299
+ status: "warn",
1300
+ message: "docker not detected"
1301
+ });
1490
1302
  }
1491
- const headers = ["REPO", "BRANCH", "AHEAD", "BEHIND", "DIRTY", "COMMIT", "SUBJECT"];
1492
- const data = rows.map((r) => [
1493
- r.repo,
1494
- r.branch ?? "-",
1495
- String(r.ahead),
1496
- String(r.behind),
1497
- String(r.dirty),
1498
- r.last_commit ?? "-",
1499
- (r.last_commit_subject ?? "").split("\n")[0]?.slice(0, 60) ?? ""
1500
- ]);
1501
- const widths = headers.map(
1502
- (h, i) => Math.max(h.length, ...data.map((row) => (row[i] ?? "").length))
1503
- );
1504
- const fmt = (row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
1505
- emit(fmt(headers));
1506
- for (const row of data) emit(fmt(row));
1507
- });
1508
- repoOption(
1509
- program.command("commit").description("Commit pending changes across selected repos.").requiredOption("-m, --message <msg>", "Commit message.").option("--allow-empty", "Allow empty commits.")
1510
- ).action(
1511
- async (opts, cmd) => {
1512
- const root = inheritRootOptions(cmd);
1513
- if (!opts.message || opts.message.trim().length === 0) {
1514
- throw new UserError(`Commit message must not be empty.`);
1303
+ try {
1304
+ const ws = await resolveWorkspace();
1305
+ await ensureDir(ws.paths.stateRoot);
1306
+ const probe = path8.join(ws.paths.stateRoot, ".doctor-write-check");
1307
+ await fs6.writeFile(probe, "");
1308
+ await fs6.unlink(probe);
1309
+ checks.push({
1310
+ name: "workspace .qavor/ writable",
1311
+ status: "ok",
1312
+ message: ws.paths.stateRoot
1313
+ });
1314
+ } catch (err) {
1315
+ checks.push({
1316
+ name: "workspace .qavor/ writable",
1317
+ status: "fail",
1318
+ message: err instanceof Error ? err.message : String(err)
1319
+ });
1320
+ }
1321
+ const cache = globalCacheDir();
1322
+ try {
1323
+ await ensureDir(cache);
1324
+ const probe = path8.join(cache, ".doctor-write-check");
1325
+ await fs6.writeFile(probe, "");
1326
+ await fs6.unlink(probe);
1327
+ checks.push({ name: "global cache writable", status: "ok", message: cache });
1328
+ } catch (err) {
1329
+ checks.push({
1330
+ name: "global cache writable",
1331
+ status: "fail",
1332
+ message: err instanceof Error ? err.message : String(err)
1333
+ });
1334
+ }
1335
+ try {
1336
+ const ws = await resolveWorkspace();
1337
+ const project = await readProjectManifest(ws.projectManifestFile);
1338
+ const repos = resolveRepos({
1339
+ workspaceRoot: ws.paths.root,
1340
+ project: project.data,
1341
+ projectRepoPath: ws.projectRepoPath
1342
+ });
1343
+ const repoMap = new Map(repos.map((r) => [r.name, r.dir]));
1344
+ repoMap.set("__project__", ws.projectRepoPath);
1345
+ const registry = await buildWorkspaceRegistry({
1346
+ workspaceRoot: ws.paths.root,
1347
+ repos: repoMap,
1348
+ concurrency: resolveJobs(root.jobs)
1349
+ });
1350
+ for (const entry of registry.entries) {
1351
+ if (entry.kind !== "service") continue;
1352
+ const svc = entry.data;
1353
+ const checkCmd = svc.runtime?.native?.check_installed?.cmd;
1354
+ if (!checkCmd) {
1355
+ checks.push({
1356
+ name: `service ${entry.name}: check_installed`,
1357
+ status: "warn",
1358
+ message: "no runtime.native.check_installed.cmd"
1359
+ });
1360
+ continue;
1361
+ }
1362
+ const docs = await loadManifestFile(entry.file);
1363
+ const serviceDoc = docs[entry.docIndex];
1364
+ const cwd = svc.runtime?.native?.check_installed?.cwd ? path8.resolve(path8.dirname(serviceDoc.file), svc.runtime.native.check_installed.cwd) : path8.dirname(serviceDoc.file);
1365
+ const res = await runShell(checkCmd, cwd);
1366
+ if (res.ok) {
1367
+ checks.push({ name: `service ${entry.name}: check_installed`, status: "ok" });
1368
+ } else {
1369
+ const installHint = svc.runtime?.native?.install?.cmd;
1370
+ const failCheck = {
1371
+ name: `service ${entry.name}: check_installed`,
1372
+ status: "fail",
1373
+ message: `exit ${res.exitCode}`
1374
+ };
1375
+ if (installHint) failCheck.hint = `Hint: \`${installHint}\``;
1376
+ checks.push(failCheck);
1377
+ }
1515
1378
  }
1516
- const { repos } = await loadProjectRepos();
1517
- const selected = await reposPresent(selectRepos(repos, opts.repo));
1518
- const jobs = resolveJobs(root.jobs);
1519
- const results = [];
1520
- await pMap2(
1521
- selected,
1522
- async (r) => {
1523
- try {
1524
- const res = await gitCommit(r.dir, opts.message, { allowEmpty: Boolean(opts.allowEmpty) });
1525
- results.push({ repo: r.name, committed: res.committed });
1526
- } catch (err) {
1527
- results.push({ repo: r.name, committed: false, error: err instanceof Error ? err.message : String(err) });
1528
- }
1529
- },
1530
- { concurrency: jobs }
1379
+ } catch (err) {
1380
+ logger.debug(
1381
+ { err: err instanceof Error ? err.message : String(err) },
1382
+ "doctor: workspace probe failed"
1531
1383
  );
1532
- if (root.json) {
1533
- emitJson({ results });
1534
- return;
1535
- }
1536
- for (const r of results) {
1537
- const verb = r.committed ? "committed" : r.error ? "failed" : "skipped";
1538
- emit(`${verb.padEnd(10)} ${r.repo}${r.error ? " \u2014 " + r.error : ""}`);
1539
- }
1540
- if (results.some((r) => r.error)) throw new RuntimeFailure("Some commits failed.");
1541
1384
  }
1542
- );
1543
- repoOption(
1544
- program.command("push").description("git push the current branch across selected repos.")
1545
- ).action(async (opts, cmd) => {
1546
- const root = inheritRootOptions(cmd);
1547
- const { repos } = await loadProjectRepos();
1548
- const selected = await reposPresent(selectRepos(repos, opts.repo));
1549
- const jobs = resolveJobs(root.jobs);
1550
- const results = [];
1551
- await pMap2(
1552
- selected,
1553
- async (r) => {
1554
- try {
1555
- await gitPush(r.dir);
1556
- results.push({ repo: r.name, ok: true });
1557
- } catch (err) {
1558
- results.push({ repo: r.name, ok: false, error: err instanceof Error ? err.message : String(err) });
1559
- }
1560
- },
1561
- { concurrency: jobs }
1562
- );
1563
1385
  if (root.json) {
1564
- emitJson({ results });
1565
- return;
1386
+ emitJson({ checks, ok: checks.every((c) => c.status !== "fail") });
1387
+ } else {
1388
+ for (const c of checks) {
1389
+ const sym = c.status === "ok" ? "\u2713" : c.status === "warn" ? "!" : "\u2717";
1390
+ let line = `${sym} ${c.status.toUpperCase().padEnd(5)} ${c.name}`;
1391
+ if (c.message) line += ` \u2014 ${c.message}`;
1392
+ emit(line);
1393
+ if (c.hint) emit(` ${c.hint}`);
1394
+ }
1395
+ }
1396
+ if (checks.some((c) => c.status === "fail")) {
1397
+ throw new RuntimeFailure("doctor: one or more checks failed.");
1566
1398
  }
1567
- for (const r of results) emit(`${r.ok ? "ok " : "fail"} ${r.repo}${r.error ? " \u2014 " + r.error : ""}`);
1568
- if (results.some((r) => !r.ok)) throw new RuntimeFailure("Some pushes failed.");
1569
1399
  });
1570
- void path11;
1571
1400
  }
1572
1401
 
1573
- // src/cli/commands/prepare.ts
1574
- import pMap4 from "p-map";
1575
-
1576
- // src/manifest/discovery.ts
1577
- import fs8 from "fs/promises";
1578
- import path12 from "path";
1579
- import pMap3 from "p-map";
1580
- var MAX_DEPTH = 4;
1581
- var SKIP_DIRS = /* @__PURE__ */ new Set([
1582
- ".git",
1583
- ".qavor",
1584
- "node_modules",
1585
- ".venv",
1586
- "venv",
1587
- "__pycache__",
1588
- "dist",
1589
- "build",
1590
- "target",
1591
- ".next",
1592
- ".svelte-kit",
1593
- ".cache"
1594
- ]);
1595
- async function discoverManifestFiles(repoRoot) {
1596
- const abs = path12.resolve(repoRoot);
1597
- const found = /* @__PURE__ */ new Set();
1598
- if (!await isDirectory(abs)) return [];
1599
- const rootFile = path12.join(abs, "qavor.yaml");
1600
- try {
1601
- await fs8.access(rootFile);
1602
- found.add(rootFile);
1603
- } catch {
1604
- }
1605
- const qavorDir = path12.join(abs, "qavor");
1606
- if (await isDirectory(qavorDir)) {
1607
- for await (const f of walk(qavorDir, qavorDir, 0)) found.add(f);
1608
- }
1609
- for await (const f of walk(abs, abs, 0)) {
1610
- found.add(f);
1611
- }
1612
- return [...found].sort();
1613
- }
1614
- async function* walk(rootBase, current, depth) {
1615
- if (depth > MAX_DEPTH) return;
1616
- let entries;
1617
- try {
1618
- entries = await fs8.readdir(current, { withFileTypes: true });
1619
- } catch {
1620
- return;
1621
- }
1622
- for (const entry of entries) {
1623
- const full = path12.join(current, entry.name);
1624
- if (entry.isDirectory()) {
1625
- if (SKIP_DIRS.has(entry.name)) continue;
1626
- if (depth === 0 && entry.name === "qavor") continue;
1627
- yield* walk(rootBase, full, depth + 1);
1628
- } else if (entry.isFile() && entry.name === "qavor.yaml") {
1629
- if (depth === 0 && current === rootBase) continue;
1630
- yield full;
1631
- }
1632
- }
1633
- }
1634
- async function buildWorkspaceRegistry(opts) {
1635
- const issues = [];
1636
- const all = [];
1637
- const reposList = [];
1638
- for (const [name, dir] of opts.repos) reposList.push({ name, dir });
1639
- await pMap3(
1640
- reposList,
1641
- async ({ name: repoName, dir }) => {
1642
- const files = await discoverManifestFiles(dir);
1643
- for (const file of files) {
1644
- let docs;
1645
- try {
1646
- docs = await loadManifestFile(file);
1647
- } catch (err) {
1648
- issues.push({
1649
- file,
1650
- line: 1,
1651
- column: 1,
1652
- kind: "unknown",
1653
- path: "",
1654
- message: err instanceof Error ? err.message : String(err)
1655
- });
1656
- continue;
1657
- }
1658
- for (const doc of docs) {
1659
- if (!isKnownKind(doc.kind)) {
1660
- const pos = doc.position("/kind");
1661
- issues.push({
1662
- file: pos.file,
1663
- line: pos.line,
1664
- column: pos.column,
1665
- kind: String(doc.kind ?? "unknown"),
1666
- path: "/kind",
1667
- message: `Unknown or missing kind in this document`
1668
- });
1669
- continue;
1670
- }
1671
- const result = validateDocument(doc);
1672
- if (!result.ok) {
1673
- issues.push(...result.issues);
1674
- continue;
1675
- }
1676
- const data = doc.data;
1677
- all.push({
1678
- kind: doc.kind,
1679
- name: typeof data.name === "string" ? data.name : "",
1680
- file: doc.file,
1681
- docIndex: doc.docIndex,
1682
- dir: path12.dirname(doc.file),
1683
- repo: repoName,
1684
- data: doc.data,
1685
- position: doc.position
1686
- });
1687
- }
1688
- }
1689
- },
1690
- { concurrency: opts.concurrency ?? 8 }
1691
- );
1692
- const byName = /* @__PURE__ */ new Map();
1693
- for (const entry of all) {
1694
- if (!entry.name) continue;
1695
- if (entry.kind === "workspaces" || entry.kind === "project") continue;
1696
- const key = entry.name;
1697
- const existing = byName.get(key);
1698
- if (existing && existing.kind === entry.kind) {
1699
- const pos = entry.position("/name");
1700
- issues.push({
1701
- file: pos.file,
1702
- line: pos.line,
1703
- column: pos.column,
1704
- kind: entry.kind,
1705
- path: "/name",
1706
- message: `Duplicate ${entry.kind} name '${entry.name}'. Already declared at ${existing.file}.`
1707
- });
1708
- continue;
1709
- }
1710
- if (!existing) byName.set(key, entry);
1711
- }
1712
- return { byName, entries: all, issues };
1713
- }
1714
-
1715
- // src/prepare/prepare.ts
1716
- import { createHash as createHash3 } from "crypto";
1717
- import { createReadStream as createReadStream2 } from "fs";
1718
- import fs10 from "fs/promises";
1719
- import path14 from "path";
1720
- import { execa as execa3 } from "execa";
1721
-
1722
- // src/env/composer.ts
1723
- import path13 from "path";
1402
+ // src/env/composer.ts
1403
+ import path9 from "path";
1724
1404
 
1725
1405
  // src/env/dotenv.ts
1726
- import fs9 from "fs/promises";
1406
+ import fs7 from "fs/promises";
1727
1407
  async function loadDotenvFile(file) {
1728
1408
  if (!await pathExists(file)) return [];
1729
- const raw = await fs9.readFile(file, "utf8");
1409
+ const raw = await fs7.readFile(file, "utf8");
1730
1410
  const lines = raw.split(/\r?\n/);
1731
1411
  const out = [];
1732
1412
  for (let i = 0; i < lines.length; i++) {
@@ -1757,18 +1437,39 @@ async function loadDotenvFile(file) {
1757
1437
  async function composeServiceEnv(input) {
1758
1438
  const issues = [];
1759
1439
  const layers = [];
1760
- const manifestDir = path13.dirname(input.serviceDoc.file);
1440
+ const manifestDir = path9.dirname(input.serviceDoc.file);
1761
1441
  const env = input.service.env;
1762
1442
  const positionFor = input.serviceDoc.position;
1763
1443
  if (env?.common) {
1764
- pushEnvMap(layers, env.common, "service.env.common", input.serviceDoc.file, positionFor, "/env/common");
1444
+ pushEnvMap(
1445
+ layers,
1446
+ env.common,
1447
+ "service.env.common",
1448
+ input.serviceDoc.file,
1449
+ positionFor,
1450
+ "/env/common"
1451
+ );
1765
1452
  }
1766
1453
  if (input.mode === "native" && env?.native) {
1767
- pushEnvMap(layers, env.native, "service.env.native", input.serviceDoc.file, positionFor, "/env/native");
1454
+ pushEnvMap(
1455
+ layers,
1456
+ env.native,
1457
+ "service.env.native",
1458
+ input.serviceDoc.file,
1459
+ positionFor,
1460
+ "/env/native"
1461
+ );
1768
1462
  } else if (input.mode === "docker" && env?.docker) {
1769
- pushEnvMap(layers, env.docker, "service.env.docker", input.serviceDoc.file, positionFor, "/env/docker");
1463
+ pushEnvMap(
1464
+ layers,
1465
+ env.docker,
1466
+ "service.env.docker",
1467
+ input.serviceDoc.file,
1468
+ positionFor,
1469
+ "/env/docker"
1470
+ );
1770
1471
  }
1771
- const baseDotenv = await loadDotenvFile(path13.join(manifestDir, ".env"));
1472
+ const baseDotenv = await loadDotenvFile(path9.join(manifestDir, ".env"));
1772
1473
  for (const e of baseDotenv) {
1773
1474
  layers.push({
1774
1475
  key: e.key,
@@ -1779,7 +1480,10 @@ async function composeServiceEnv(input) {
1779
1480
  spec: null
1780
1481
  });
1781
1482
  }
1782
- const modeDotenvFile = path13.join(manifestDir, input.mode === "native" ? ".env.native" : ".env.docker");
1483
+ const modeDotenvFile = path9.join(
1484
+ manifestDir,
1485
+ input.mode === "native" ? ".env.native" : ".env.docker"
1486
+ );
1783
1487
  const modeDotenv = await loadDotenvFile(modeDotenvFile);
1784
1488
  for (const e of modeDotenv) {
1785
1489
  layers.push({
@@ -1791,7 +1495,7 @@ async function composeServiceEnv(input) {
1791
1495
  spec: null
1792
1496
  });
1793
1497
  }
1794
- const wsEnv = await loadDotenvFile(path13.join(input.workspaceRoot, ".env"));
1498
+ const wsEnv = await loadDotenvFile(path9.join(input.workspaceRoot, ".env"));
1795
1499
  for (const e of wsEnv) {
1796
1500
  layers.push({
1797
1501
  key: e.key,
@@ -1914,54 +1618,529 @@ function interpolateLayers(layers, issues) {
1914
1618
  });
1915
1619
  }
1916
1620
  }
1917
- return { values, issues };
1918
- }
1919
- function interpolate(raw, resolved, procEnv) {
1920
- if (!raw) return { value: raw, missing: [], secrets: [] };
1921
- const missing = [];
1922
- const secrets = [];
1923
- const value = raw.replace(INTERP_RE, (_m, expr) => {
1924
- const trimmed = expr.trim();
1925
- if (trimmed.startsWith(SECRET_PREFIX)) {
1926
- secrets.push(trimmed.slice(SECRET_PREFIX.length));
1927
- return "";
1928
- }
1929
- const fromResolved = resolved.get(trimmed);
1930
- if (fromResolved) return fromResolved.value;
1931
- const fromProc = procEnv[trimmed];
1932
- if (typeof fromProc === "string") return fromProc;
1933
- missing.push(trimmed);
1934
- return "";
1621
+ return { values, issues };
1622
+ }
1623
+ function interpolate(raw, resolved, procEnv) {
1624
+ if (!raw) return { value: raw, missing: [], secrets: [] };
1625
+ const missing = [];
1626
+ const secrets = [];
1627
+ const value = raw.replace(INTERP_RE, (_m, expr) => {
1628
+ const trimmed = expr.trim();
1629
+ if (trimmed.startsWith(SECRET_PREFIX)) {
1630
+ secrets.push(trimmed.slice(SECRET_PREFIX.length));
1631
+ return "";
1632
+ }
1633
+ const fromResolved = resolved.get(trimmed);
1634
+ if (fromResolved) return fromResolved.value;
1635
+ const fromProc = procEnv[trimmed];
1636
+ if (typeof fromProc === "string") return fromProc;
1637
+ missing.push(trimmed);
1638
+ return "";
1639
+ });
1640
+ return { value, missing, secrets };
1641
+ }
1642
+ function parseCliEnv(items) {
1643
+ const out = {};
1644
+ for (const item of items) {
1645
+ const eq = item.indexOf("=");
1646
+ if (eq <= 0) throw new UserError(`Invalid --env value '${item}'. Expected KEY=VALUE.`);
1647
+ const key = item.slice(0, eq);
1648
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
1649
+ throw new UserError(`Invalid env key '${key}' in --env. Use UPPER_SNAKE_CASE.`);
1650
+ }
1651
+ out[key] = item.slice(eq + 1);
1652
+ }
1653
+ return out;
1654
+ }
1655
+ function toEnvObject(resolved) {
1656
+ const out = {};
1657
+ for (const [k, v] of resolved.values) out[k] = v.value;
1658
+ return out;
1659
+ }
1660
+ function assertNoIssues(resolved) {
1661
+ if (resolved.issues.length === 0) return;
1662
+ const lines = resolved.issues.map((i) => `${i.file}:${i.line}: ${i.message}`);
1663
+ throw new ManifestError(`Environment composition failed:
1664
+ ${lines.join("\n ")}`);
1665
+ }
1666
+
1667
+ // src/cli/commands/env.ts
1668
+ function registerEnv(program) {
1669
+ program.command("env").description("Print the fully-resolved environment for a service, with provenance per key.").argument("<service>", "Service name.").option("--mode <mode>", "native | docker (default: native).", "native").option("--env <kv...>", "Layer KEY=VAL on top of the composed env.").action(async (service, opts, cmd) => {
1670
+ const root = inheritRootOptions(cmd);
1671
+ const mode = opts.mode === "docker" ? "docker" : "native";
1672
+ if (opts.mode !== "native" && opts.mode !== "docker") {
1673
+ throw new UserError(`--mode must be 'native' or 'docker'.`);
1674
+ }
1675
+ const ws = await resolveWorkspace();
1676
+ const projectDoc = await readProjectManifest(ws.projectManifestFile);
1677
+ const repos = resolveRepos({
1678
+ workspaceRoot: ws.paths.root,
1679
+ project: projectDoc.data,
1680
+ projectRepoPath: ws.projectRepoPath
1681
+ });
1682
+ const repoMap = new Map(repos.map((r) => [r.name, r.dir]));
1683
+ repoMap.set("__project__", ws.projectRepoPath);
1684
+ const registry = await buildWorkspaceRegistry({
1685
+ workspaceRoot: ws.paths.root,
1686
+ repos: repoMap,
1687
+ concurrency: resolveJobs(root.jobs)
1688
+ });
1689
+ const entry = registry.entries.find((e) => e.kind === "service" && e.name === service);
1690
+ if (!entry) throw new UserError(`Service '${service}' not found in workspace.`);
1691
+ const docs = await loadManifestFile(entry.file);
1692
+ const serviceDoc = docs[entry.docIndex];
1693
+ const cliEnv = opts.env ? parseCliEnv(opts.env) : void 0;
1694
+ const composeOpts = {
1695
+ mode,
1696
+ serviceDoc,
1697
+ service: entry.data,
1698
+ workspaceRoot: ws.paths.root
1699
+ };
1700
+ if (cliEnv) composeOpts.cliEnv = cliEnv;
1701
+ const resolved = await composeServiceEnv(composeOpts);
1702
+ if (root.json) {
1703
+ emitJson({
1704
+ service,
1705
+ mode,
1706
+ issues: resolved.issues,
1707
+ env: [...resolved.values].map(([k, v]) => ({
1708
+ key: k,
1709
+ value: v.secret ? "<redacted>" : v.value,
1710
+ secret: v.secret,
1711
+ required: v.required,
1712
+ provenance: v.provenance.map((p) => ({
1713
+ file: p.file,
1714
+ line: p.line,
1715
+ layer: p.layer,
1716
+ raw: v.secret ? "<redacted>" : p.raw
1717
+ }))
1718
+ }))
1719
+ });
1720
+ return;
1721
+ }
1722
+ if (resolved.issues.length > 0) {
1723
+ emit("Issues:");
1724
+ for (const i of resolved.issues) emit(` ${i.file}:${i.line}: ${i.message}`);
1725
+ emit("");
1726
+ }
1727
+ emit(`Resolved environment for ${service} (mode=${mode}):`);
1728
+ for (const [k, v] of resolved.values) {
1729
+ const printed = v.secret ? "<redacted>" : v.value;
1730
+ emit(` ${k} = ${printed}`);
1731
+ for (const p of v.provenance) {
1732
+ const rawPrinted = v.secret ? "<redacted>" : p.raw;
1733
+ emit(` via ${p.layer} (${p.file}:${p.line}) = ${rawPrinted}`);
1734
+ }
1735
+ }
1736
+ });
1737
+ }
1738
+
1739
+ // src/cli/commands/git.ts
1740
+ import path10 from "path";
1741
+ import pMap2 from "p-map";
1742
+
1743
+ // src/cli/repos.ts
1744
+ function selectRepos(all, selector) {
1745
+ if (!selector || selector.length === 0) return all;
1746
+ const set = new Set(selector);
1747
+ const out = [];
1748
+ for (const r of all) {
1749
+ if (set.has(r.name)) {
1750
+ out.push(r);
1751
+ set.delete(r.name);
1752
+ }
1753
+ }
1754
+ if (set.size > 0) {
1755
+ throw new Error(`Unknown repo${set.size > 1 ? "s" : ""}: ${[...set].join(", ")}`);
1756
+ }
1757
+ return out;
1758
+ }
1759
+ async function reposPresent(repos) {
1760
+ const out = [];
1761
+ for (const r of repos) {
1762
+ if (await isDirectory(r.dir)) out.push(r);
1763
+ }
1764
+ return out;
1765
+ }
1766
+
1767
+ // src/cli/commands/git.ts
1768
+ async function loadProjectRepos() {
1769
+ const ws = await resolveWorkspace();
1770
+ const project = await readProjectManifest(ws.projectManifestFile);
1771
+ const repos = resolveRepos({
1772
+ workspaceRoot: ws.paths.root,
1773
+ project: project.data,
1774
+ projectRepoPath: ws.projectRepoPath
1775
+ });
1776
+ return { workspaceRoot: ws.paths.root, repos };
1777
+ }
1778
+ function repoOption(c) {
1779
+ return c.option("--repo <name...>", "Operate on a subset of repos by name.");
1780
+ }
1781
+ function registerGitCommands(program) {
1782
+ repoOption(
1783
+ program.command("clone").description("Clone every repo enumerated in the project manifest.")
1784
+ ).action(async (opts, cmd) => {
1785
+ const root = inheritRootOptions(cmd);
1786
+ const logger = getLogger();
1787
+ const { workspaceRoot, repos } = await loadProjectRepos();
1788
+ const selected = selectRepos(repos, opts.repo);
1789
+ const jobs = resolveJobs(root.jobs);
1790
+ const results = [];
1791
+ await pMap2(
1792
+ selected,
1793
+ async (r) => {
1794
+ if (r.isProjectRepo) {
1795
+ results.push({
1796
+ repo: r.name,
1797
+ status: "present",
1798
+ message: "project repo (already cloned)"
1799
+ });
1800
+ return;
1801
+ }
1802
+ if (await isGitRepo(r.dir)) {
1803
+ results.push({ repo: r.name, status: "present" });
1804
+ return;
1805
+ }
1806
+ try {
1807
+ logger.info({ repo: r.name, url: r.url, dir: r.dir }, "clone: starting");
1808
+ await gitClone({
1809
+ url: r.url,
1810
+ dest: r.dir,
1811
+ branch: r.branch,
1812
+ tag: r.tag,
1813
+ commit: r.commit,
1814
+ shallow: r.shallow,
1815
+ submodules: r.submodules
1816
+ });
1817
+ results.push({ repo: r.name, status: "cloned" });
1818
+ } catch (err) {
1819
+ if (r.optional) {
1820
+ results.push({ repo: r.name, status: "skipped", message: "optional; clone failed" });
1821
+ } else {
1822
+ throw new RuntimeFailure(
1823
+ `Clone failed for ${r.name}: ${err instanceof Error ? err.message : String(err)}`
1824
+ );
1825
+ }
1826
+ }
1827
+ },
1828
+ { concurrency: jobs }
1829
+ );
1830
+ if (root.json) {
1831
+ emitJson({ workspace: workspaceRoot, results });
1832
+ return;
1833
+ }
1834
+ for (const r of results) {
1835
+ emit(`${r.status.padEnd(8)} ${r.repo}${r.message ? ` \u2014 ${r.message}` : ""}`);
1836
+ }
1837
+ });
1838
+ repoOption(
1839
+ program.command("sync").description("Run `git fetch && git pull --ff-only` across selected repos.")
1840
+ ).action(async (opts, cmd) => {
1841
+ const root = inheritRootOptions(cmd);
1842
+ const { repos } = await loadProjectRepos();
1843
+ const selected = await reposPresent(selectRepos(repos, opts.repo));
1844
+ const jobs = resolveJobs(root.jobs);
1845
+ const results = [];
1846
+ await pMap2(
1847
+ selected,
1848
+ async (r) => {
1849
+ try {
1850
+ await gitFetch(r.dir);
1851
+ await gitPullFastForward(r.dir);
1852
+ results.push({ repo: r.name, ok: true });
1853
+ } catch (err) {
1854
+ results.push({
1855
+ repo: r.name,
1856
+ ok: false,
1857
+ error: err instanceof Error ? err.message : String(err)
1858
+ });
1859
+ }
1860
+ },
1861
+ { concurrency: jobs }
1862
+ );
1863
+ if (root.json) {
1864
+ emitJson({ results });
1865
+ return;
1866
+ }
1867
+ for (const r of results)
1868
+ emit(`${r.ok ? "ok " : "fail"} ${r.repo}${r.error ? ` \u2014 ${r.error}` : ""}`);
1869
+ if (results.some((r) => !r.ok)) throw new RuntimeFailure("Some repos failed to sync.");
1870
+ });
1871
+ repoOption(
1872
+ program.command("status").description("Aggregated repo status across selected repos.")
1873
+ ).action(async (opts, cmd) => {
1874
+ const root = inheritRootOptions(cmd);
1875
+ const { workspaceRoot, repos } = await loadProjectRepos();
1876
+ const selected = await reposPresent(selectRepos(repos, opts.repo));
1877
+ const jobs = resolveJobs(root.jobs);
1878
+ const rows = await pMap2(
1879
+ selected,
1880
+ async (r) => {
1881
+ const s = await readRepoStatus(r.dir);
1882
+ return {
1883
+ repo: r.name,
1884
+ branch: s.branch,
1885
+ ahead: s.ahead,
1886
+ behind: s.behind,
1887
+ dirty: s.dirtyCount,
1888
+ last_commit: s.lastCommit,
1889
+ last_commit_subject: s.lastCommitSubject
1890
+ };
1891
+ },
1892
+ { concurrency: jobs }
1893
+ );
1894
+ if (root.json) {
1895
+ emitJson({ workspace: workspaceRoot, repos: rows });
1896
+ return;
1897
+ }
1898
+ const headers = ["REPO", "BRANCH", "AHEAD", "BEHIND", "DIRTY", "COMMIT", "SUBJECT"];
1899
+ const data = rows.map((r) => [
1900
+ r.repo,
1901
+ r.branch ?? "-",
1902
+ String(r.ahead),
1903
+ String(r.behind),
1904
+ String(r.dirty),
1905
+ r.last_commit ?? "-",
1906
+ (r.last_commit_subject ?? "").split("\n")[0]?.slice(0, 60) ?? ""
1907
+ ]);
1908
+ const widths = headers.map(
1909
+ (h, i) => Math.max(h.length, ...data.map((row) => (row[i] ?? "").length))
1910
+ );
1911
+ const fmt = (row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
1912
+ emit(fmt(headers));
1913
+ for (const row of data) emit(fmt(row));
1914
+ });
1915
+ repoOption(
1916
+ program.command("commit").description("Commit pending changes across selected repos.").requiredOption("-m, --message <msg>", "Commit message.").option("--allow-empty", "Allow empty commits.")
1917
+ ).action(
1918
+ async (opts, cmd) => {
1919
+ const root = inheritRootOptions(cmd);
1920
+ if (!opts.message || opts.message.trim().length === 0) {
1921
+ throw new UserError(`Commit message must not be empty.`);
1922
+ }
1923
+ const { repos } = await loadProjectRepos();
1924
+ const selected = await reposPresent(selectRepos(repos, opts.repo));
1925
+ const jobs = resolveJobs(root.jobs);
1926
+ const results = [];
1927
+ await pMap2(
1928
+ selected,
1929
+ async (r) => {
1930
+ try {
1931
+ const res = await gitCommit(r.dir, opts.message, {
1932
+ allowEmpty: Boolean(opts.allowEmpty)
1933
+ });
1934
+ results.push({ repo: r.name, committed: res.committed });
1935
+ } catch (err) {
1936
+ results.push({
1937
+ repo: r.name,
1938
+ committed: false,
1939
+ error: err instanceof Error ? err.message : String(err)
1940
+ });
1941
+ }
1942
+ },
1943
+ { concurrency: jobs }
1944
+ );
1945
+ if (root.json) {
1946
+ emitJson({ results });
1947
+ return;
1948
+ }
1949
+ for (const r of results) {
1950
+ const verb = r.committed ? "committed" : r.error ? "failed" : "skipped";
1951
+ emit(`${verb.padEnd(10)} ${r.repo}${r.error ? ` \u2014 ${r.error}` : ""}`);
1952
+ }
1953
+ if (results.some((r) => r.error)) throw new RuntimeFailure("Some commits failed.");
1954
+ }
1955
+ );
1956
+ repoOption(
1957
+ program.command("push").description("git push the current branch across selected repos.")
1958
+ ).action(async (opts, cmd) => {
1959
+ const root = inheritRootOptions(cmd);
1960
+ const { repos } = await loadProjectRepos();
1961
+ const selected = await reposPresent(selectRepos(repos, opts.repo));
1962
+ const jobs = resolveJobs(root.jobs);
1963
+ const results = [];
1964
+ await pMap2(
1965
+ selected,
1966
+ async (r) => {
1967
+ try {
1968
+ await gitPush(r.dir);
1969
+ results.push({ repo: r.name, ok: true });
1970
+ } catch (err) {
1971
+ results.push({
1972
+ repo: r.name,
1973
+ ok: false,
1974
+ error: err instanceof Error ? err.message : String(err)
1975
+ });
1976
+ }
1977
+ },
1978
+ { concurrency: jobs }
1979
+ );
1980
+ if (root.json) {
1981
+ emitJson({ results });
1982
+ return;
1983
+ }
1984
+ for (const r of results)
1985
+ emit(`${r.ok ? "ok " : "fail"} ${r.repo}${r.error ? ` \u2014 ${r.error}` : ""}`);
1986
+ if (results.some((r) => !r.ok)) throw new RuntimeFailure("Some pushes failed.");
1987
+ });
1988
+ void path10;
1989
+ }
1990
+
1991
+ // src/cli/commands/init.ts
1992
+ import path12 from "path";
1993
+
1994
+ // src/workspace/init.ts
1995
+ import { createHash as createHash2 } from "crypto";
1996
+ import fs8 from "fs/promises";
1997
+ import path11 from "path";
1998
+ var URL_RE = /^(?:git@[^:]+:|https?:\/\/|git:\/\/|ssh:\/\/|file:\/\/)/;
1999
+ function looksLikeGitUrl(s) {
2000
+ return URL_RE.test(s);
2001
+ }
2002
+ function projectRepoNameFromUrl(url) {
2003
+ const cleaned = url.replace(/[?#].*$/, "");
2004
+ const last = cleaned.split("/").pop() ?? cleaned.split(":").pop() ?? "project";
2005
+ return last.replace(/\.git$/, "");
2006
+ }
2007
+ async function initWorkspace(opts) {
2008
+ const workspaceRoot = path11.resolve(opts.into ?? process.cwd());
2009
+ await ensureDir(workspaceRoot);
2010
+ const paths = workspacePaths(workspaceRoot);
2011
+ let projectRepoPath;
2012
+ let cloned = false;
2013
+ if (looksLikeGitUrl(opts.source)) {
2014
+ const repoName = projectRepoNameFromUrl(opts.source);
2015
+ const target = path11.join(workspaceRoot, `${repoName}.git`);
2016
+ if (await isDirectory(target)) {
2017
+ if (!await isGitRepo(target)) {
2018
+ throw new UserError(
2019
+ `Cannot reuse ${target}: directory exists but is not a git repo. Move it aside and re-run.`
2020
+ );
2021
+ }
2022
+ opts.logger.info({ target }, "reusing existing project repo clone");
2023
+ projectRepoPath = target;
2024
+ } else {
2025
+ const cacheDir = path11.join(globalCacheDir(), "projects", urlHash(opts.source));
2026
+ await ensureDir(path11.dirname(cacheDir));
2027
+ opts.logger.info({ url: opts.source, target }, "cloning project repo");
2028
+ await gitClone({ url: opts.source, dest: target });
2029
+ try {
2030
+ await ensureDir(cacheDir);
2031
+ await fs8.writeFile(
2032
+ path11.join(cacheDir, "source.json"),
2033
+ JSON.stringify(
2034
+ { url: opts.source, cloned_to: target, at: (/* @__PURE__ */ new Date()).toISOString() },
2035
+ null,
2036
+ 2
2037
+ )
2038
+ );
2039
+ } catch {
2040
+ }
2041
+ projectRepoPath = target;
2042
+ cloned = true;
2043
+ }
2044
+ } else {
2045
+ const localPath = path11.resolve(opts.source);
2046
+ if (!await isDirectory(localPath)) {
2047
+ throw new UserError(`Project repo source does not exist or is not a directory: ${localPath}`);
2048
+ }
2049
+ projectRepoPath = localPath;
2050
+ }
2051
+ const projectManifestFile = path11.join(projectRepoPath, "qavor.yaml");
2052
+ const docs = await loadManifestFile(projectManifestFile);
2053
+ const projectDoc = docs.find((d) => d.kind === "project");
2054
+ if (!projectDoc) {
2055
+ throw new ManifestError(
2056
+ `Project repo at ${projectRepoPath} is missing a \`kind: project\` document in qavor.yaml.`
2057
+ );
2058
+ }
2059
+ const result = validateDocument(projectDoc);
2060
+ if (!result.ok) {
2061
+ const msg = result.issues.map((i) => ` ${i.file}:${i.line}:${i.column} ${i.path}: ${i.message}`).join("\n");
2062
+ throw new ManifestError(`Invalid project manifest:
2063
+ ${msg}`);
2064
+ }
2065
+ const project = projectDoc.data;
2066
+ await ensureDir(paths.stateRoot);
2067
+ await ensureDir(paths.stateDir);
2068
+ await ensureDir(paths.logsDir);
2069
+ await ensureDir(paths.composeDir);
2070
+ await ensureDir(paths.cacheDir);
2071
+ await fs8.writeFile(
2072
+ paths.stateGitignore,
2073
+ [
2074
+ "# qavor state directory \u2014 all files are generated. Do not commit.",
2075
+ "*",
2076
+ "!.gitignore",
2077
+ ""
2078
+ ].join("\n")
2079
+ );
2080
+ const relProjectPath = `./${path11.relative(workspaceRoot, projectRepoPath).split(path11.sep).join("/")}`;
2081
+ const workspacesYaml = renderWorkspacesYaml(relProjectPath);
2082
+ await fs8.writeFile(paths.workspacesFile, workspacesYaml, "utf8");
2083
+ const manifestHash = createHash2("sha256").update(await fs8.readFile(projectManifestFile)).digest("hex");
2084
+ await writeJsonFile(paths.workspaceMetaFile, {
2085
+ project_name: project.name,
2086
+ project_repo_path: projectRepoPath,
2087
+ manifest_hash: manifestHash,
2088
+ initialized_at: (/* @__PURE__ */ new Date()).toISOString()
1935
2089
  });
1936
- return { value, missing, secrets };
2090
+ return { paths, projectRepoPath, project, cloned };
1937
2091
  }
1938
- function parseCliEnv(items) {
1939
- const out = {};
1940
- for (const item of items) {
1941
- const eq = item.indexOf("=");
1942
- if (eq <= 0) throw new UserError(`Invalid --env value '${item}'. Expected KEY=VALUE.`);
1943
- const key = item.slice(0, eq);
1944
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
1945
- throw new UserError(`Invalid env key '${key}' in --env. Use UPPER_SNAKE_CASE.`);
1946
- }
1947
- out[key] = item.slice(eq + 1);
1948
- }
1949
- return out;
2092
+ function urlHash(url) {
2093
+ return createHash2("sha256").update(url).digest("hex").slice(0, 16);
1950
2094
  }
1951
- function toEnvObject(resolved) {
1952
- const out = {};
1953
- for (const [k, v] of resolved.values) out[k] = v.value;
1954
- return out;
2095
+ function renderWorkspacesYaml(relProjectPath) {
2096
+ return [
2097
+ "# Generated by `qavor init`. Points at the project repo whose",
2098
+ "# `kind: project` manifest enumerates the rest of the workspace.",
2099
+ "kind: workspaces",
2100
+ `root_project_path: ${relProjectPath}`,
2101
+ ""
2102
+ ].join("\n");
1955
2103
  }
1956
- function assertNoIssues(resolved) {
1957
- if (resolved.issues.length === 0) return;
1958
- const lines = resolved.issues.map((i) => `${i.file}:${i.line}: ${i.message}`);
1959
- throw new ManifestError(`Environment composition failed:
1960
- ${lines.join("\n ")}`);
2104
+
2105
+ // src/cli/commands/init.ts
2106
+ function registerInit(program) {
2107
+ program.command("init").description("Bootstrap a workspace from a project repo (local path or git URL).").argument("<source>", "Local path to a project repo, or a git URL.").option("--into <dir>", "Workspace root directory. Defaults to the current directory.").action(async (source, opts, cmd) => {
2108
+ const root = inheritRootOptions(cmd);
2109
+ const logger = getLogger();
2110
+ const initOpts = { source, logger };
2111
+ if (opts.into) initOpts.into = opts.into;
2112
+ const result = await initWorkspace(initOpts);
2113
+ if (root.json) {
2114
+ emitJson({
2115
+ ok: true,
2116
+ workspace: result.paths.root,
2117
+ project_name: result.project.name,
2118
+ project_repo_path: result.projectRepoPath,
2119
+ cloned_project: result.cloned,
2120
+ repositories: result.project.repositories.length
2121
+ });
2122
+ } else {
2123
+ emit(`Workspace initialized at ${result.paths.root}`);
2124
+ emit(` project: ${result.project.name}`);
2125
+ emit(` project repo: ${path12.relative(result.paths.root, result.projectRepoPath)}`);
2126
+ emit(` repositories declared: ${result.project.repositories.length}`);
2127
+ emit(` next: qavor clone`);
2128
+ }
2129
+ });
1961
2130
  }
1962
2131
 
2132
+ // src/cli/commands/prepare.ts
2133
+ import pMap3 from "p-map";
2134
+
2135
+ // src/prepare/prepare.ts
2136
+ import { createHash as createHash3 } from "crypto";
2137
+ import { createReadStream as createReadStream2 } from "fs";
2138
+ import fs9 from "fs/promises";
2139
+ import path13 from "path";
2140
+ import { execa as execa4 } from "execa";
2141
+
1963
2142
  // src/util/hooks.ts
1964
- import { execa as execa2 } from "execa";
2143
+ import { execa as execa3 } from "execa";
1965
2144
  function toList(cmds) {
1966
2145
  if (!cmds) return [];
1967
2146
  return Array.isArray(cmds) ? [...cmds] : [cmds];
@@ -1972,7 +2151,7 @@ async function runHooks(opts) {
1972
2151
  for (const cmd of cmds) {
1973
2152
  opts.logger.info({ event: opts.event, cmd }, "hook: running");
1974
2153
  try {
1975
- await execa2("/bin/sh", ["-c", cmd], {
2154
+ await execa3("/bin/sh", ["-c", cmd], {
1976
2155
  cwd: opts.cwd,
1977
2156
  env: opts.env ? { ...process.env, ...opts.env } : process.env,
1978
2157
  stdout: "inherit",
@@ -2009,13 +2188,16 @@ async function prepareService(input) {
2009
2188
  hash: null
2010
2189
  };
2011
2190
  }
2012
- const manifestDir = path14.dirname(input.serviceDoc.file);
2013
- const cacheFile = path14.join(input.paths.cacheDir, "prepare", `${input.service.name}.json`);
2191
+ const manifestDir = path13.dirname(input.serviceDoc.file);
2192
+ const cacheFile = path13.join(input.paths.cacheDir, "prepare", `${input.service.name}.json`);
2014
2193
  const hash = await computePrepareHash(manifestDir, cmd);
2015
2194
  if (!input.force) {
2016
2195
  const prev = await readPrev(cacheFile);
2017
2196
  if (prev && prev.hash === hash) {
2018
- input.logger.info({ service: input.service.name }, "prepare: lockfile hash unchanged; skipping");
2197
+ input.logger.info(
2198
+ { service: input.service.name },
2199
+ "prepare: lockfile hash unchanged; skipping"
2200
+ );
2019
2201
  return { serviceName: input.service.name, status: "skipped", cacheFile, hash };
2020
2202
  }
2021
2203
  }
@@ -2036,11 +2218,11 @@ async function prepareService(input) {
2036
2218
  logger: input.logger,
2037
2219
  ...input.signal ? { signal: input.signal } : {}
2038
2220
  });
2039
- await ensureDir(path14.join(input.paths.logsDir, input.service.name));
2040
- const logFile = path14.join(input.paths.logsDir, input.service.name, "prepare.log");
2221
+ await ensureDir(path13.join(input.paths.logsDir, input.service.name));
2222
+ const logFile = path13.join(input.paths.logsDir, input.service.name, "prepare.log");
2041
2223
  input.logger.info({ service: input.service.name, cmd }, "prepare: starting");
2042
- const cwd = input.service.runtime?.native?.prepare?.cwd ? path14.resolve(manifestDir, input.service.runtime.native.prepare.cwd) : manifestDir;
2043
- const fileHandle = await fs10.open(logFile, "a");
2224
+ const cwd = input.service.runtime?.native?.prepare?.cwd ? path13.resolve(manifestDir, input.service.runtime.native.prepare.cwd) : manifestDir;
2225
+ const fileHandle = await fs9.open(logFile, "a");
2044
2226
  const shell = input.service.runtime?.native?.prepare?.shell ?? "/bin/sh";
2045
2227
  try {
2046
2228
  const opts = {
@@ -2050,7 +2232,7 @@ async function prepareService(input) {
2050
2232
  ...input.signal ? { cancelSignal: input.signal } : {},
2051
2233
  reject: false
2052
2234
  };
2053
- const res = await execa3(shell, ["-c", cmd], opts);
2235
+ const res = await execa4(shell, ["-c", cmd], opts);
2054
2236
  if (res.exitCode !== 0) {
2055
2237
  throw new RuntimeFailure(
2056
2238
  `prepare failed for ${input.service.name} (exit ${res.exitCode}). See ${logFile}.`
@@ -2091,9 +2273,10 @@ async function readPrev(file) {
2091
2273
  }
2092
2274
  async function computePrepareHash(manifestDir, cmd) {
2093
2275
  const hash = createHash3("sha256");
2094
- hash.update("cmd:" + cmd + "\n");
2276
+ hash.update(`cmd:${cmd}
2277
+ `);
2095
2278
  for (const candidate of DEFAULT_LOCK_PATTERNS) {
2096
- const file = path14.join(manifestDir, candidate);
2279
+ const file = path13.join(manifestDir, candidate);
2097
2280
  const stat = await safeStat(file);
2098
2281
  if (!stat) {
2099
2282
  hash.update(`missing:${candidate}
@@ -2112,7 +2295,7 @@ mtime:${stat.mtimeMs}
2112
2295
  }
2113
2296
  async function safeStat(file) {
2114
2297
  try {
2115
- return await fs10.stat(file);
2298
+ return await fs9.stat(file);
2116
2299
  } catch {
2117
2300
  return null;
2118
2301
  }
@@ -2162,7 +2345,7 @@ function registerPrepare(program) {
2162
2345
  }
2163
2346
  const cliEnv = opts.env ? parseCliEnv(opts.env) : void 0;
2164
2347
  const jobs = resolveJobs(root.jobs);
2165
- const results = await pMap4(
2348
+ const results = await pMap3(
2166
2349
  services,
2167
2350
  async (entry) => {
2168
2351
  const docs = await loadManifestFile(entry.file);
@@ -2191,82 +2374,84 @@ function registerPrepare(program) {
2191
2374
  );
2192
2375
  }
2193
2376
 
2194
- // src/cli/commands/env.ts
2195
- function registerEnv(program) {
2196
- program.command("env").description("Print the fully-resolved environment for a service, with provenance per key.").argument("<service>", "Service name.").option("--mode <mode>", "native | docker (default: native).", "native").option("--env <kv...>", "Layer KEY=VAL on top of the composed env.").action(async (service, opts, cmd) => {
2197
- const root = inheritRootOptions(cmd);
2198
- const mode = opts.mode === "docker" ? "docker" : "native";
2199
- if (opts.mode !== "native" && opts.mode !== "docker") {
2200
- throw new UserError(`--mode must be 'native' or 'docker'.`);
2201
- }
2202
- const ws = await resolveWorkspace();
2203
- const projectDoc = await readProjectManifest(ws.projectManifestFile);
2204
- const repos = resolveRepos({
2205
- workspaceRoot: ws.paths.root,
2206
- project: projectDoc.data,
2207
- projectRepoPath: ws.projectRepoPath
2208
- });
2209
- const repoMap = new Map(repos.map((r) => [r.name, r.dir]));
2210
- repoMap.set("__project__", ws.projectRepoPath);
2211
- const registry = await buildWorkspaceRegistry({
2212
- workspaceRoot: ws.paths.root,
2213
- repos: repoMap,
2214
- concurrency: resolveJobs(root.jobs)
2215
- });
2216
- const entry = registry.entries.find((e) => e.kind === "service" && e.name === service);
2217
- if (!entry) throw new UserError(`Service '${service}' not found in workspace.`);
2218
- const docs = await loadManifestFile(entry.file);
2219
- const serviceDoc = docs[entry.docIndex];
2220
- const cliEnv = opts.env ? parseCliEnv(opts.env) : void 0;
2221
- const composeOpts = {
2222
- mode,
2223
- serviceDoc,
2224
- service: entry.data,
2225
- workspaceRoot: ws.paths.root
2226
- };
2227
- if (cliEnv) composeOpts.cliEnv = cliEnv;
2228
- const resolved = await composeServiceEnv(composeOpts);
2229
- if (root.json) {
2230
- emitJson({
2231
- service,
2232
- mode,
2233
- issues: resolved.issues,
2234
- env: [...resolved.values].map(([k, v]) => ({
2235
- key: k,
2236
- value: v.secret ? "<redacted>" : v.value,
2237
- secret: v.secret,
2238
- required: v.required,
2239
- provenance: v.provenance.map((p) => ({
2240
- file: p.file,
2241
- line: p.line,
2242
- layer: p.layer,
2243
- raw: v.secret ? "<redacted>" : p.raw
2244
- }))
2245
- }))
2246
- });
2247
- return;
2248
- }
2249
- if (resolved.issues.length > 0) {
2250
- emit("Issues:");
2251
- for (const i of resolved.issues) emit(` ${i.file}:${i.line}: ${i.message}`);
2252
- emit("");
2377
+ // src/cli/commands/run.ts
2378
+ import path17 from "path";
2379
+
2380
+ // src/supervisor/logs.ts
2381
+ import { createReadStream as createReadStream3 } from "fs";
2382
+ import fs10 from "fs/promises";
2383
+ import path14 from "path";
2384
+ async function tailFile(opts) {
2385
+ if (!await pathExists(opts.file)) {
2386
+ if (!opts.follow) return;
2387
+ await waitForFile(opts.file, opts.signal);
2388
+ }
2389
+ let offset = await initialOffset(opts.file, opts.initialBytes ?? 16 * 1024);
2390
+ await streamFrom(opts.file, offset, opts.out);
2391
+ if (!opts.follow) return;
2392
+ const stat = await fs10.stat(opts.file);
2393
+ offset = stat.size;
2394
+ const watcher = fs10.watch(opts.file, { persistent: true });
2395
+ const abort = opts.signal ?? new AbortController().signal;
2396
+ const pollTimer = setInterval(async () => {
2397
+ try {
2398
+ const cur = await fs10.stat(opts.file);
2399
+ if (cur.size < offset) {
2400
+ offset = 0;
2401
+ }
2402
+ if (cur.size > offset) {
2403
+ await streamFrom(opts.file, offset, opts.out);
2404
+ offset = cur.size;
2405
+ }
2406
+ } catch {
2253
2407
  }
2254
- emit(`Resolved environment for ${service} (mode=${mode}):`);
2255
- for (const [k, v] of resolved.values) {
2256
- const printed = v.secret ? "<redacted>" : v.value;
2257
- emit(` ${k} = ${printed}`);
2258
- for (const p of v.provenance) {
2259
- const rawPrinted = v.secret ? "<redacted>" : p.raw;
2260
- emit(` via ${p.layer} (${p.file}:${p.line}) = ${rawPrinted}`);
2408
+ }, 500);
2409
+ try {
2410
+ for await (const _event of watcher) {
2411
+ if (abort.aborted) break;
2412
+ const cur = await fs10.stat(opts.file).catch(() => null);
2413
+ if (!cur) continue;
2414
+ if (cur.size < offset) {
2415
+ offset = 0;
2416
+ }
2417
+ if (cur.size > offset) {
2418
+ await streamFrom(opts.file, offset, opts.out);
2419
+ offset = cur.size;
2261
2420
  }
2262
2421
  }
2422
+ } catch (err) {
2423
+ if (err.code === "ABORT_ERR") return;
2424
+ throw err;
2425
+ } finally {
2426
+ clearInterval(pollTimer);
2427
+ }
2428
+ }
2429
+ async function initialOffset(file, fromEndBytes) {
2430
+ const st = await fs10.stat(file);
2431
+ return Math.max(0, st.size - fromEndBytes);
2432
+ }
2433
+ async function streamFrom(file, start, out) {
2434
+ return new Promise((resolve, reject) => {
2435
+ const rs = createReadStream3(file, { start, encoding: "utf8" });
2436
+ rs.on("data", (chunk) => out.write(chunk));
2437
+ rs.on("end", () => resolve());
2438
+ rs.on("error", (err) => reject(err));
2263
2439
  });
2264
2440
  }
2441
+ async function waitForFile(file, signal) {
2442
+ const dir = path14.dirname(file);
2443
+ await fs10.mkdir(dir, { recursive: true });
2444
+ for (; ; ) {
2445
+ if (signal?.aborted) return;
2446
+ if (await pathExists(file)) return;
2447
+ await new Promise((r) => setTimeout(r, 250));
2448
+ }
2449
+ }
2265
2450
 
2266
2451
  // src/supervisor/native.ts
2267
2452
  import fs12 from "fs/promises";
2268
2453
  import path16 from "path";
2269
- import { execa as execa4 } from "execa";
2454
+ import { execa as execa5 } from "execa";
2270
2455
 
2271
2456
  // src/supervisor/state.ts
2272
2457
  import fs11 from "fs/promises";
@@ -2374,7 +2559,7 @@ async function startNativeService(opts) {
2374
2559
  stdio: ["ignore", fh.fd, fh.fd],
2375
2560
  reject: false
2376
2561
  };
2377
- child = execa4(shell, ["-c", cmd], spawnOpts);
2562
+ child = execa5(shell, ["-c", cmd], spawnOpts);
2378
2563
  } catch (err) {
2379
2564
  await fh.close().catch(() => void 0);
2380
2565
  throw new RuntimeFailure(
@@ -2411,7 +2596,10 @@ async function stopNativeService(opts) {
2411
2596
  return { stopped: false };
2412
2597
  }
2413
2598
  if (!isPidAlive(state.pid)) {
2414
- opts.logger.info({ service: opts.service, pid: state.pid }, "down: process already gone; clearing state");
2599
+ opts.logger.info(
2600
+ { service: opts.service, pid: state.pid },
2601
+ "down: process already gone; clearing state"
2602
+ );
2415
2603
  await clearState(opts.paths, opts.service);
2416
2604
  return { stopped: true };
2417
2605
  }
@@ -2505,79 +2693,7 @@ async function listServicesState(paths) {
2505
2693
  return out;
2506
2694
  }
2507
2695
 
2508
- // src/supervisor/logs.ts
2509
- import fs13 from "fs/promises";
2510
- import { createReadStream as createReadStream3 } from "fs";
2511
- import path17 from "path";
2512
- async function tailFile(opts) {
2513
- if (!await pathExists(opts.file)) {
2514
- if (!opts.follow) return;
2515
- await waitForFile(opts.file, opts.signal);
2516
- }
2517
- let offset = await initialOffset(opts.file, opts.initialBytes ?? 16 * 1024);
2518
- await streamFrom(opts.file, offset, opts.out);
2519
- if (!opts.follow) return;
2520
- let stat = await fs13.stat(opts.file);
2521
- offset = stat.size;
2522
- const watcher = fs13.watch(opts.file, { persistent: true });
2523
- const abort = opts.signal ?? new AbortController().signal;
2524
- const pollTimer = setInterval(async () => {
2525
- try {
2526
- const cur = await fs13.stat(opts.file);
2527
- if (cur.size < offset) {
2528
- offset = 0;
2529
- }
2530
- if (cur.size > offset) {
2531
- await streamFrom(opts.file, offset, opts.out);
2532
- offset = cur.size;
2533
- }
2534
- } catch {
2535
- }
2536
- }, 500);
2537
- try {
2538
- for await (const _event of watcher) {
2539
- if (abort.aborted) break;
2540
- const cur = await fs13.stat(opts.file).catch(() => null);
2541
- if (!cur) continue;
2542
- if (cur.size < offset) {
2543
- offset = 0;
2544
- }
2545
- if (cur.size > offset) {
2546
- await streamFrom(opts.file, offset, opts.out);
2547
- offset = cur.size;
2548
- }
2549
- }
2550
- } catch (err) {
2551
- if (err.code === "ABORT_ERR") return;
2552
- throw err;
2553
- } finally {
2554
- clearInterval(pollTimer);
2555
- }
2556
- }
2557
- async function initialOffset(file, fromEndBytes) {
2558
- const st = await fs13.stat(file);
2559
- return Math.max(0, st.size - fromEndBytes);
2560
- }
2561
- async function streamFrom(file, start, out) {
2562
- return new Promise((resolve, reject) => {
2563
- const rs = createReadStream3(file, { start, encoding: "utf8" });
2564
- rs.on("data", (chunk) => out.write(chunk));
2565
- rs.on("end", () => resolve());
2566
- rs.on("error", (err) => reject(err));
2567
- });
2568
- }
2569
- async function waitForFile(file, signal) {
2570
- const dir = path17.dirname(file);
2571
- await fs13.mkdir(dir, { recursive: true });
2572
- for (; ; ) {
2573
- if (signal?.aborted) return;
2574
- if (await pathExists(file)) return;
2575
- await new Promise((r) => setTimeout(r, 250));
2576
- }
2577
- }
2578
-
2579
2696
  // src/cli/commands/run.ts
2580
- import path18 from "path";
2581
2697
  async function findService(name, jobs) {
2582
2698
  const ws = await resolveWorkspace();
2583
2699
  const projectDoc = await readProjectManifest(ws.projectManifestFile);
@@ -2601,7 +2717,7 @@ async function findService(name, jobs) {
2601
2717
  serviceDoc,
2602
2718
  service: entry.data,
2603
2719
  paths: ws.paths,
2604
- manifestDir: path18.dirname(entry.file)
2720
+ manifestDir: path17.dirname(entry.file)
2605
2721
  };
2606
2722
  }
2607
2723
  function registerRunCommands(program) {
@@ -2654,7 +2770,7 @@ function registerRunCommands(program) {
2654
2770
  const root = inheritRootOptions(cmd);
2655
2771
  const jobs = resolveJobs(root.jobs);
2656
2772
  const ctx = await findService(name, jobs);
2657
- const logFile = path18.join(ctx.paths.logsDir, name, "service.log");
2773
+ const logFile = path17.join(ctx.paths.logsDir, name, "service.log");
2658
2774
  const ac = new AbortController();
2659
2775
  process.on("SIGINT", () => ac.abort());
2660
2776
  process.on("SIGTERM", () => ac.abort());
@@ -2687,136 +2803,133 @@ function registerRunCommands(program) {
2687
2803
  s.uptimeSec !== null ? `${s.uptimeSec}s` : "-",
2688
2804
  s.logFile ?? "-"
2689
2805
  ]);
2690
- const widths = headers.map((h, i) => Math.max(h.length, ...data.map((row) => (row[i] ?? "").length)));
2806
+ const widths = headers.map(
2807
+ (h, i) => Math.max(h.length, ...data.map((row) => (row[i] ?? "").length))
2808
+ );
2691
2809
  const fmt = (row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
2692
2810
  emit(fmt(headers));
2693
2811
  for (const row of data) emit(fmt(row));
2694
2812
  });
2695
2813
  }
2696
2814
 
2697
- // src/cli/commands/doctor.ts
2698
- import path19 from "path";
2699
- import fs14 from "fs/promises";
2700
- import { execa as execa5 } from "execa";
2701
- async function runShell(cmd, cwd) {
2702
- try {
2703
- const res = await execa5("/bin/sh", ["-c", cmd], { cwd, reject: false });
2704
- return { ok: res.exitCode === 0, exitCode: res.exitCode ?? -1 };
2705
- } catch {
2706
- return { ok: false, exitCode: -1 };
2707
- }
2708
- }
2709
- function registerDoctor(program) {
2710
- program.command("doctor").description("Verify toolchain prerequisites, workspace paths, and per-service check_installed steps.").action(async (_opts, cmd) => {
2815
+ // src/cli/commands/validate.ts
2816
+ import fs13 from "fs/promises";
2817
+ import path18 from "path";
2818
+ import pMap4 from "p-map";
2819
+ function registerValidate(program) {
2820
+ program.command("validate").description("Validate one or more qavor manifest files. Targets a file or a directory.").argument(
2821
+ "<path>",
2822
+ "Path to a qavor.yaml file, a directory containing one, or a directory of multiple manifests."
2823
+ ).action(async (target, _opts, cmd) => {
2711
2824
  const root = inheritRootOptions(cmd);
2712
2825
  const logger = getLogger();
2713
- const checks = [];
2714
- try {
2715
- const res = await execa5("git", ["--version"]);
2716
- const version = res.stdout.trim().replace(/^git version /, "");
2717
- const [maj, min] = version.split(".").map((s) => Number.parseInt(s, 10));
2718
- if (Number.isFinite(maj) && Number.isFinite(min) && ((maj ?? 0) > 2 || (maj ?? 0) === 2 && (min ?? 0) >= 30)) {
2719
- checks.push({ name: "git \u2265 2.30", status: "ok", message: version });
2826
+ const abs = path18.resolve(target);
2827
+ const files = [];
2828
+ if (await isFile(abs)) {
2829
+ files.push(abs);
2830
+ } else if (await isDirectory(abs)) {
2831
+ const direct = path18.join(abs, "qavor.yaml");
2832
+ if (await isFile(direct)) files.push(direct);
2833
+ try {
2834
+ const entries = await fs13.readdir(abs, { withFileTypes: true });
2835
+ for (const e of entries) {
2836
+ if (e.isDirectory()) {
2837
+ const child = path18.join(abs, e.name, "qavor.yaml");
2838
+ if (await isFile(child)) files.push(child);
2839
+ }
2840
+ if (e.isFile() && e.name === "qavor.yaml") {
2841
+ }
2842
+ }
2843
+ } catch {
2844
+ }
2845
+ } else {
2846
+ throw new UserError(`Path not found: ${abs}`);
2847
+ }
2848
+ if (files.length === 0) throw new UserError(`No qavor.yaml files found under ${abs}.`);
2849
+ const jobs = resolveJobs(root.jobs);
2850
+ const issues = [];
2851
+ await pMap4(
2852
+ files,
2853
+ async (file) => {
2854
+ try {
2855
+ const docs = await loadManifestFile(file);
2856
+ for (const d of docs) {
2857
+ const r = validateDocument(d);
2858
+ if (!r.ok) issues.push(...r.issues);
2859
+ }
2860
+ } catch (err) {
2861
+ issues.push({
2862
+ file,
2863
+ line: 1,
2864
+ column: 1,
2865
+ kind: "unknown",
2866
+ path: "",
2867
+ message: err instanceof Error ? err.message : String(err)
2868
+ });
2869
+ }
2870
+ },
2871
+ { concurrency: jobs }
2872
+ );
2873
+ if (root.json) {
2874
+ emitJson({ ok: issues.length === 0, files: files.length, issues });
2875
+ } else {
2876
+ if (issues.length === 0) {
2877
+ emit(`OK \u2014 ${files.length} file(s) validated.`);
2720
2878
  } else {
2721
- checks.push({ name: "git \u2265 2.30", status: "warn", message: `found ${version}` });
2879
+ emit(`FAILED \u2014 ${issues.length} issue(s) across ${files.length} file(s):`);
2880
+ for (const i of issues) emit(` ${formatIssue(i)}`);
2722
2881
  }
2723
- } catch {
2724
- checks.push({ name: "git \u2265 2.30", status: "fail", message: "git not found", hint: "Install git." });
2725
2882
  }
2883
+ if (issues.length > 0) {
2884
+ logger.debug({ count: issues.length }, "validation failed");
2885
+ throw new ManifestError(`Validation failed with ${issues.length} issue(s).`);
2886
+ }
2887
+ });
2888
+ }
2889
+
2890
+ // src/cli/commands/workspace.ts
2891
+ import fs14 from "fs/promises";
2892
+ import path19 from "path";
2893
+ function registerWorkspace(program) {
2894
+ const ws = program.command("workspace").description("Workspace operations.");
2895
+ ws.command("info").description("Show information about the workspace at or above the cwd.").action(async (_opts, cmd) => {
2896
+ const root = inheritRootOptions(cmd);
2897
+ const resolved = await resolveWorkspace();
2898
+ const project = await readProjectManifest(resolved.projectManifestFile);
2899
+ let meta = {};
2726
2900
  try {
2727
- await execa5("docker", ["--version"]);
2728
- checks.push({ name: "docker (optional v0)", status: "ok" });
2901
+ meta = await readJsonFile(resolved.paths.workspaceMetaFile);
2729
2902
  } catch {
2730
- checks.push({ name: "docker (optional v0)", status: "warn", message: "docker not detected" });
2731
2903
  }
2732
- try {
2733
- const ws = await resolveWorkspace();
2734
- await ensureDir(ws.paths.stateRoot);
2735
- const probe = path19.join(ws.paths.stateRoot, ".doctor-write-check");
2736
- await fs14.writeFile(probe, "");
2737
- await fs14.unlink(probe);
2738
- checks.push({ name: "workspace .qavor/ writable", status: "ok", message: ws.paths.stateRoot });
2739
- } catch (err) {
2740
- checks.push({
2741
- name: "workspace .qavor/ writable",
2742
- status: "fail",
2743
- message: err instanceof Error ? err.message : String(err)
2744
- });
2904
+ const info = {
2905
+ workspace_root: resolved.paths.root,
2906
+ workspaces_file: resolved.paths.workspacesFile,
2907
+ project_repo_path: resolved.projectRepoPath,
2908
+ project_manifest_file: resolved.projectManifestFile,
2909
+ project_name: typeof project.data.name === "string" ? project.data.name : null,
2910
+ state_dir: resolved.paths.stateRoot,
2911
+ meta
2912
+ };
2913
+ if (root.json) {
2914
+ emitJson(info);
2915
+ return;
2745
2916
  }
2746
- const cache = globalCacheDir();
2747
- try {
2748
- await ensureDir(cache);
2749
- const probe = path19.join(cache, ".doctor-write-check");
2750
- await fs14.writeFile(probe, "");
2751
- await fs14.unlink(probe);
2752
- checks.push({ name: "global cache writable", status: "ok", message: cache });
2753
- } catch (err) {
2754
- checks.push({
2755
- name: "global cache writable",
2756
- status: "fail",
2757
- message: err instanceof Error ? err.message : String(err)
2758
- });
2917
+ emit(`Workspace root: ${info.workspace_root}`);
2918
+ emit(`Workspaces manifest: ${path19.relative(info.workspace_root, info.workspaces_file)}`);
2919
+ emit(`Project repo path: ${path19.relative(info.workspace_root, info.project_repo_path)}`);
2920
+ emit(
2921
+ `Project manifest: ${path19.relative(info.workspace_root, info.project_manifest_file)}`
2922
+ );
2923
+ emit(`Project name: ${info.project_name ?? "<unknown>"}`);
2924
+ emit(`State directory: ${path19.relative(info.workspace_root, info.state_dir)}`);
2925
+ if (Object.keys(meta).length > 0) {
2926
+ emit("Workspace meta:");
2927
+ for (const [k, v] of Object.entries(meta))
2928
+ emit(` ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
2759
2929
  }
2760
2930
  try {
2761
- const ws = await resolveWorkspace();
2762
- const project = await readProjectManifest(ws.projectManifestFile);
2763
- const repos = resolveRepos({
2764
- workspaceRoot: ws.paths.root,
2765
- project: project.data,
2766
- projectRepoPath: ws.projectRepoPath
2767
- });
2768
- const repoMap = new Map(repos.map((r) => [r.name, r.dir]));
2769
- repoMap.set("__project__", ws.projectRepoPath);
2770
- const registry = await buildWorkspaceRegistry({
2771
- workspaceRoot: ws.paths.root,
2772
- repos: repoMap,
2773
- concurrency: resolveJobs(root.jobs)
2774
- });
2775
- for (const entry of registry.entries) {
2776
- if (entry.kind !== "service") continue;
2777
- const svc = entry.data;
2778
- const checkCmd = svc.runtime?.native?.check_installed?.cmd;
2779
- if (!checkCmd) {
2780
- checks.push({
2781
- name: `service ${entry.name}: check_installed`,
2782
- status: "warn",
2783
- message: "no runtime.native.check_installed.cmd"
2784
- });
2785
- continue;
2786
- }
2787
- const docs = await loadManifestFile(entry.file);
2788
- const serviceDoc = docs[entry.docIndex];
2789
- const cwd = svc.runtime?.native?.check_installed?.cwd ? path19.resolve(path19.dirname(serviceDoc.file), svc.runtime.native.check_installed.cwd) : path19.dirname(serviceDoc.file);
2790
- const res = await runShell(checkCmd, cwd);
2791
- if (res.ok) {
2792
- checks.push({ name: `service ${entry.name}: check_installed`, status: "ok" });
2793
- } else {
2794
- const installHint = svc.runtime?.native?.install?.cmd;
2795
- const failCheck = {
2796
- name: `service ${entry.name}: check_installed`,
2797
- status: "fail",
2798
- message: `exit ${res.exitCode}`
2799
- };
2800
- if (installHint) failCheck.hint = `Hint: \`${installHint}\``;
2801
- checks.push(failCheck);
2802
- }
2803
- }
2804
- } catch (err) {
2805
- logger.debug({ err: err instanceof Error ? err.message : String(err) }, "doctor: workspace probe failed");
2806
- }
2807
- if (root.json) {
2808
- emitJson({ checks, ok: checks.every((c) => c.status !== "fail") });
2809
- } else {
2810
- for (const c of checks) {
2811
- const sym = c.status === "ok" ? "\u2713" : c.status === "warn" ? "!" : "\u2717";
2812
- let line = `${sym} ${c.status.toUpperCase().padEnd(5)} ${c.name}`;
2813
- if (c.message) line += ` \u2014 ${c.message}`;
2814
- emit(line);
2815
- if (c.hint) emit(` ${c.hint}`);
2816
- }
2817
- }
2818
- if (checks.some((c) => c.status === "fail")) {
2819
- throw new RuntimeFailure("doctor: one or more checks failed.");
2931
+ await fs14.access(resolved.paths.workspaceMetaFile);
2932
+ } catch {
2820
2933
  }
2821
2934
  });
2822
2935
  }
@@ -2825,7 +2938,9 @@ function registerDoctor(program) {
2825
2938
  var PKG_VERSION = "0.1.0";
2826
2939
  function buildProgram() {
2827
2940
  const program = new Command();
2828
- program.name("qavor").description("A CLI for managing a constellation of related repositories as one cohesive developer workspace.").version(PKG_VERSION, "-V, --version").option("--json", "Emit machine-readable JSON output. One object per line on stdout.").option("-v, --verbose", "Enable debug-level logging on stderr.").option("-c, --config <path>", "Override the path to the workspace pointer file.").option("-j, --jobs <n>", "Maximum concurrency for fan-out operations.", (raw) => {
2941
+ program.name("qavor").description(
2942
+ "A CLI for managing a constellation of related repositories as one cohesive developer workspace."
2943
+ ).version(PKG_VERSION, "-V, --version").option("--json", "Emit machine-readable JSON output. One object per line on stdout.").option("-v, --verbose", "Enable debug-level logging on stderr.").option("-c, --config <path>", "Override the path to the workspace pointer file.").option("-j, --jobs <n>", "Maximum concurrency for fan-out operations.", (raw) => {
2829
2944
  const n = Number.parseInt(raw, 10);
2830
2945
  if (!Number.isFinite(n) || n < 1) {
2831
2946
  throw new Error(`--jobs must be a positive integer (got '${raw}').`);
@@ -2856,7 +2971,8 @@ async function main(argv) {
2856
2971
  }
2857
2972
  function handleError(err) {
2858
2973
  const e = err;
2859
- if (e && (e.code === "commander.helpDisplayed" || e.code === "commander.help")) return ExitCode.Ok;
2974
+ if (e && (e.code === "commander.helpDisplayed" || e.code === "commander.help"))
2975
+ return ExitCode.Ok;
2860
2976
  if (e && e.code === "commander.version") return ExitCode.Ok;
2861
2977
  if (e && typeof e.code === "string" && e.code.startsWith("commander.")) {
2862
2978
  process2.stderr.write(`${e.message ?? "command error"}