qavor 0.1.9 → 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 +1363 -1247
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
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/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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/
|
|
85
|
-
import
|
|
86
|
-
import
|
|
87
|
-
import
|
|
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)
|
|
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/
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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 =
|
|
106
|
+
const absFile = path2.resolve(filePath);
|
|
279
107
|
let source;
|
|
280
108
|
try {
|
|
281
|
-
source = await
|
|
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: {
|
|
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: {
|
|
452
|
-
|
|
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: {
|
|
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: {
|
|
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.
|
|
545
|
-
var
|
|
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.
|
|
548
|
-
title: "qavor
|
|
549
|
-
description: "
|
|
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", "
|
|
389
|
+
required: ["kind", "name"],
|
|
553
390
|
properties: {
|
|
554
|
-
kind: { const: "
|
|
555
|
-
schemaVersion: {
|
|
556
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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.
|
|
808
|
-
var
|
|
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.
|
|
811
|
-
title: "qavor
|
|
812
|
-
description: "
|
|
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", "
|
|
675
|
+
required: ["kind", "root_project_path"],
|
|
816
676
|
properties: {
|
|
817
|
-
kind: { const: "
|
|
818
|
-
schemaVersion: {
|
|
819
|
-
|
|
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
|
-
|
|
831
|
-
mode: {
|
|
681
|
+
root_project_path: {
|
|
832
682
|
type: "string",
|
|
833
|
-
|
|
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/
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
|
1030
|
-
|
|
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
|
-
|
|
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
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
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/
|
|
1120
|
-
import
|
|
1121
|
-
import
|
|
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 =
|
|
1015
|
+
let cur = path5.resolve(start);
|
|
1128
1016
|
for (let i = 0; i < 64; i++) {
|
|
1129
|
-
const candidate =
|
|
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 =
|
|
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(
|
|
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 =
|
|
1163
|
-
const projectManifestFile =
|
|
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/
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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 ?
|
|
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:
|
|
1237
|
+
isProjectRepo: path7.resolve(dir) === path7.resolve(opts.projectRepoPath)
|
|
1338
1238
|
});
|
|
1339
1239
|
}
|
|
1340
1240
|
return repos;
|
|
1341
1241
|
}
|
|
1342
1242
|
|
|
1343
|
-
// src/cli/
|
|
1344
|
-
function
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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/
|
|
1370
|
-
async function
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
).action(async (
|
|
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
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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({
|
|
1565
|
-
|
|
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/
|
|
1574
|
-
import
|
|
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
|
|
1406
|
+
import fs7 from "fs/promises";
|
|
1727
1407
|
async function loadDotenvFile(file) {
|
|
1728
1408
|
if (!await pathExists(file)) return [];
|
|
1729
|
-
const raw = await
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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 {
|
|
2090
|
+
return { paths, projectRepoPath, project, cloned };
|
|
1937
2091
|
}
|
|
1938
|
-
function
|
|
1939
|
-
|
|
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
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
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
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
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
|
|
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
|
|
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 =
|
|
2013
|
-
const cacheFile =
|
|
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(
|
|
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(
|
|
2040
|
-
const logFile =
|
|
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 ?
|
|
2043
|
-
const fileHandle = await
|
|
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
|
|
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(
|
|
2276
|
+
hash.update(`cmd:${cmd}
|
|
2277
|
+
`);
|
|
2095
2278
|
for (const candidate of DEFAULT_LOCK_PATTERNS) {
|
|
2096
|
-
const file =
|
|
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
|
|
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
|
|
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/
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
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
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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:
|
|
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 =
|
|
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(
|
|
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/
|
|
2698
|
-
import
|
|
2699
|
-
import
|
|
2700
|
-
import
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
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
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
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
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
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
|
-
|
|
2762
|
-
|
|
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(
|
|
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"))
|
|
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"}
|