@nyaitter/nap 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/publish.js ADDED
@@ -0,0 +1,772 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { git, gitText, refExists } = require('./git');
7
+ const {
8
+ MANIFEST_NAME,
9
+ VERSIONS_BRANCH,
10
+ INDEX_BRANCH,
11
+ sha256Hex,
12
+ packageIdOf,
13
+ versionIdOf,
14
+ manifestBuffer,
15
+ validateLabel,
16
+ tagRefFor,
17
+ isSourceSpec,
18
+ deriveVersionCode,
19
+ parseManifest,
20
+ } = require('./core');
21
+ const { MODULES_DIR } = require('./get');
22
+ const { DECLARATION_FILE, parseDeclaration } = require('./declaration');
23
+ const { createMatcher } = require('./glob');
24
+ const { readIndexAt } = require('./read');
25
+
26
+ const COMMITTER_ENV = {
27
+ GIT_AUTHOR_NAME: 'nap',
28
+ GIT_AUTHOR_EMAIL: 'nap@localhost',
29
+ GIT_COMMITTER_NAME: 'nap',
30
+ GIT_COMMITTER_EMAIL: 'nap@localhost',
31
+ };
32
+ const DEFAULT_CHUNK_SIZE = 200;
33
+
34
+ function resolveRepoRoot(spec) {
35
+ const dir = spec ? path.resolve(spec) : process.cwd();
36
+ return gitText(['rev-parse', '--show-toplevel'], { cwd: dir });
37
+ }
38
+
39
+ function readBlobAt(root, commit, rel) {
40
+ try {
41
+ return git(['cat-file', 'blob', `${commit}:${rel}`], { cwd: root });
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // 対象コミットのツリーから、宣言に一致するファイルのハッシュとオブジェクトIDを集める。
48
+ // 宣言ファイルとマニフェスト自身は対象に含めない。
49
+ function snapshotFiles(root, commit, include) {
50
+ const matcher = createMatcher(include);
51
+ const out = git(['ls-tree', '-r', '-z', commit], { cwd: root });
52
+ const files = {};
53
+ const objects = {};
54
+ for (const record of out.toString('utf8').split('\0')) {
55
+ if (!record) continue;
56
+ const tab = record.indexOf('\t');
57
+ const [mode, type, oid] = record.slice(0, tab).split(' ');
58
+ const rel = record.slice(tab + 1);
59
+ if (type !== 'blob') continue;
60
+ if (mode !== '100644' && mode !== '100755') continue;
61
+ if (rel === DECLARATION_FILE || rel === MANIFEST_NAME) continue;
62
+ if (!matcher(rel)) continue;
63
+ const content = git(['cat-file', 'blob', oid], { cwd: root });
64
+ files[rel] = sha256Hex(content);
65
+ objects[rel] = { oid, mode };
66
+ }
67
+ return { files, objects };
68
+ }
69
+
70
+ // リモートにブランチがあれば取り込む。無い場合はここから作る。
71
+ function fetchBranch(root, remote, branch) {
72
+ let exists = false;
73
+ try {
74
+ exists =
75
+ gitText(['ls-remote', remote, `refs/heads/${branch}`], { cwd: root }).length > 0;
76
+ } catch {
77
+ exists = false;
78
+ }
79
+ if (!exists) return;
80
+ git(['fetch', '--quiet', remote, `+refs/heads/${branch}:refs/heads/${branch}`], {
81
+ cwd: root,
82
+ });
83
+ }
84
+
85
+ // 出典を含むブランチを返す。出典は対象コミットのこと。
86
+ // --ref で指定されたブランチを優先し、次に現在のブランチを見る。
87
+ // どちらにも含まれなければ null。
88
+ function sourceBranchFor(root, ref, commit) {
89
+ const candidates = [];
90
+ if (ref) candidates.push(ref);
91
+ try {
92
+ candidates.push(gitText(['symbolic-ref', '--short', 'HEAD'], { cwd: root }));
93
+ } catch {
94
+ // detached HEAD では現在のブランチは無い
95
+ }
96
+ for (const name of candidates) {
97
+ let tip;
98
+ try {
99
+ tip = gitText(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], { cwd: root });
100
+ } catch {
101
+ continue;
102
+ }
103
+ if (tip === commit) return name;
104
+ try {
105
+ git(['merge-base', '--is-ancestor', commit, `refs/heads/${name}`], { cwd: root });
106
+ return name;
107
+ } catch {
108
+ // このブランチには含まれていない
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ // 出典を含むブランチを push する。版が公開される時点で中身は出ているため、
115
+ // 出典も辿れるよう同時に公開するのが既定。
116
+ function pushSourceBranch(root, remote, ref, commit) {
117
+ const branch = sourceBranchFor(root, ref, commit);
118
+ if (!branch) return;
119
+ git(['push', '--quiet', remote, `${branch}:${branch}`], { cwd: root });
120
+ console.log(`出典を含むブランチ ${branch} を ${remote} へ push しました`);
121
+ }
122
+
123
+ function remoteTagTarget(root, remote, tagRef) {
124
+ let out = '';
125
+ try {
126
+ out = gitText(['ls-remote', remote, `refs/tags/${tagRef}`], { cwd: root });
127
+ } catch {
128
+ return null;
129
+ }
130
+ if (!out) return null;
131
+ return out.split(/\s+/)[0];
132
+ }
133
+
134
+ // 一時インデックスでツリーを組み立てる。
135
+ function buildTree(root, baseTree, entries, blobs) {
136
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nap-index-'));
137
+ const indexFile = path.join(tmp, 'index');
138
+ const env = { GIT_INDEX_FILE: indexFile };
139
+ try {
140
+ if (baseTree) git(['read-tree', baseTree], { cwd: root, env });
141
+ else git(['read-tree', '--empty'], { cwd: root, env });
142
+ for (const { mode, oid, rel } of entries) {
143
+ git(['update-index', '--add', '--cacheinfo', `${mode},${oid},${rel}`], {
144
+ cwd: root,
145
+ env,
146
+ });
147
+ }
148
+ for (const [rel, content] of Object.entries(blobs)) {
149
+ const blob = gitText(['hash-object', '-w', '--stdin'], {
150
+ cwd: root,
151
+ env,
152
+ input: content,
153
+ });
154
+ git(['update-index', '--add', '--cacheinfo', `100644,${blob},${rel}`], {
155
+ cwd: root,
156
+ env,
157
+ });
158
+ }
159
+ return gitText(['write-tree'], { cwd: root, env });
160
+ } finally {
161
+ fs.rmSync(tmp, { recursive: true, force: true });
162
+ }
163
+ }
164
+
165
+ function commitTree(root, tree, parents, message) {
166
+ const args = ['commit-tree', tree];
167
+ for (const parent of parents) {
168
+ if (parent) args.push('-p', parent);
169
+ }
170
+ return gitText(args, { cwd: root, env: COMMITTER_ENV, input: Buffer.from(message) });
171
+ }
172
+
173
+ function chunkFileName(index) {
174
+ return `chunk-${String(index).padStart(4, '0')}.json`;
175
+ }
176
+
177
+ // 索引へ書く1件。tag はラベルのことで、TestVersion のときだけ持つ。
178
+ function indexRecord(entry) {
179
+ const record = { id: entry.id, commit: entry.commit, at: entry.at };
180
+ if (entry.tag) record.tag = entry.tag;
181
+ if (entry.source) record.source = entry.source;
182
+ if (entry.code) record.code = entry.code;
183
+ return record;
184
+ }
185
+
186
+ // 索引へ1件追加するコミットを作って push する。既にあれば何もしない。
187
+ // 同じ版IDでも、正式版と TestVersion、ラベルの違う TestVersion は別の件として扱う。
188
+ function appendIndex(root, remote, entry, chunkSize, push = true) {
189
+ const ref = `refs/heads/${INDEX_BRANCH}`;
190
+ const { head, entries } = readIndexAt(root, ref);
191
+ if (entries.some((e) => e.id === entry.id && (e.tag || null) === (entry.tag || null))) {
192
+ return false;
193
+ }
194
+
195
+ const chunks = head.chunks.map((c) => ({ file: c.file, count: c.count }));
196
+ let targetFile;
197
+ let targetEntries;
198
+ if (chunks.length > 0 && chunks[chunks.length - 1].count < chunkSize) {
199
+ targetFile = chunks[chunks.length - 1].file;
200
+ targetEntries = entries.filter((e) => e.chunkFile === targetFile);
201
+ chunks[chunks.length - 1].count += 1;
202
+ } else {
203
+ targetFile = chunkFileName(chunks.length + 1);
204
+ targetEntries = [];
205
+ chunks.push({ file: targetFile, count: 1 });
206
+ }
207
+ targetEntries.push(entry);
208
+
209
+ const newHead = { chunks, total: head.total + 1 };
210
+ const blobs = {
211
+ 'index.json': Buffer.from(`${JSON.stringify(newHead, null, 2)}\n`, 'utf8'),
212
+ [targetFile]: Buffer.from(
213
+ `${JSON.stringify(targetEntries.map(indexRecord), null, 2)}\n`,
214
+ 'utf8',
215
+ ),
216
+ };
217
+ const baseTree = refExists(root, ref) ? gitText(['rev-parse', `${ref}^{tree}`], { cwd: root }) : null;
218
+ const tree = buildTree(root, baseTree, [], blobs);
219
+ const parent = refExists(root, ref) ? gitText(['rev-parse', ref], { cwd: root }) : null;
220
+ const label = entry.tag ? `-${entry.tag}` : '';
221
+ const commit = commitTree(root, tree, parent ? [parent] : [], `index: add ${entry.id.slice(0, 12)}${label}\n`);
222
+ git(['update-ref', ref, commit], { cwd: root });
223
+ if (push) git(['push', '--quiet', remote, `${ref}:${ref}`], { cwd: root });
224
+ return true;
225
+ }
226
+
227
+ // 構成員の解決元を読み込む。解決元は --libraries の JSON。
228
+ // { "<組織ID>/<名前>": { "repo": "<マニフェストに残すソース>", "version"?: "<版ID>", "source"?: "<パス|URL>" } }
229
+ function readLibrariesSpec(specPath) {
230
+ if (!specPath) return {};
231
+ let doc;
232
+ try {
233
+ doc = JSON.parse(fs.readFileSync(specPath, 'utf8'));
234
+ } catch (error) {
235
+ throw new Error(`--libraries のファイルが読めません: ${error.message}`);
236
+ }
237
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
238
+ throw new Error('--libraries の形式が不正です。オブジェクトで書いてください。');
239
+ }
240
+ return doc;
241
+ }
242
+
243
+ function isUrlSpec(spec) {
244
+ return /^(?:https?|ssh|git):\/\//.test(spec) || spec.startsWith('git@') || spec.startsWith('file://');
245
+ }
246
+
247
+ // 構成員のリポジトリを開く。ローカルパスはそのまま、URL は一時クローン。
248
+ function openLibraryRepo(source) {
249
+ if (!isUrlSpec(source) && fs.existsSync(source)) {
250
+ const dir = path.resolve(source);
251
+ gitText(['rev-parse', '--show-toplevel'], { cwd: dir });
252
+ return { dir, cleanup: () => {} };
253
+ }
254
+ if (isUrlSpec(source)) {
255
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nap-lib-'));
256
+ const dir = path.join(tmp, 'repo');
257
+ git(['clone', '--quiet', source, dir], {});
258
+ return {
259
+ dir,
260
+ cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }),
261
+ };
262
+ }
263
+ throw new Error(`構成員のリポジトリを開けません: ${source}`);
264
+ }
265
+
266
+ // 構成員の最新版を、そのリポジトリの索引から解決する。
267
+ // 解決するのは正式版だけであり、TestVersion は対象にしない。
268
+ function resolveLatestVersion(source, ref) {
269
+ const { dir, cleanup } = openLibraryRepo(source);
270
+ try {
271
+ let remoteHasIndex = false;
272
+ try {
273
+ remoteHasIndex =
274
+ gitText(['ls-remote', 'origin', `refs/heads/${INDEX_BRANCH}`], { cwd: dir })
275
+ .length > 0;
276
+ } catch {
277
+ remoteHasIndex = false;
278
+ }
279
+ if (remoteHasIndex) {
280
+ git(
281
+ [
282
+ 'fetch',
283
+ '--quiet',
284
+ 'origin',
285
+ `+refs/heads/${INDEX_BRANCH}:refs/nap-resolve/${INDEX_BRANCH}`,
286
+ ],
287
+ { cwd: dir },
288
+ );
289
+ }
290
+ const candidates = [
291
+ `refs/nap-resolve/${INDEX_BRANCH}`,
292
+ `refs/heads/${INDEX_BRANCH}`,
293
+ `refs/remotes/origin/${INDEX_BRANCH}`,
294
+ ];
295
+ for (const candidate of candidates) {
296
+ const { entries } = readIndexAt(dir, candidate);
297
+ if (entries.length === 0) continue;
298
+ const formal = entries.filter((e) => !e.tag);
299
+ if (formal.length > 0) return formal[formal.length - 1].id;
300
+ throw new Error(
301
+ `構成員 ${ref} には正式版がまだありません。開発中の版だけが発行されています。`,
302
+ );
303
+ }
304
+ throw new Error(`構成員 ${ref} にはまだ発行された版がありません。`);
305
+ } finally {
306
+ cleanup();
307
+ }
308
+ }
309
+
310
+ // 導入済みの構成員を対象コミットのツリーから確かめる。導入済みとは nap_modules に実物があること。
311
+ // 導入時のマニフェストとツリー内の実物のハッシュが一致すること、宣言で版が
312
+ // 固定されていればその版であることを確認する。戻り値は { "<参照>": "<版ID>" }。
313
+ function readInstalledMembers(root, commit, declaration, snapshot) {
314
+ const bundled = {};
315
+ if (!declaration.composition) return bundled;
316
+ for (const member of declaration.composition) {
317
+ const base = `${MODULES_DIR}/${member.ref}`;
318
+ const manifestText = readBlobAt(root, commit, `${base}/version-manifest.json`);
319
+ if (manifestText === null) continue;
320
+ let installed;
321
+ try {
322
+ installed = parseManifest(manifestText);
323
+ } catch (error) {
324
+ throw new Error(
325
+ `導入済みの構成員 ${member.ref} のマニフェストが読めません: ${error.message}`,
326
+ );
327
+ }
328
+ const installedId = versionIdOf(installed.files, installed.libraries);
329
+ if (member.version && member.version !== installedId) {
330
+ throw new Error(
331
+ `導入済みの構成員 ${member.ref} の版 ${installedId} が、宣言で固定された版 ${member.version} と違います。`,
332
+ );
333
+ }
334
+ for (const [rel, hash] of Object.entries(installed.files)) {
335
+ const full = `${base}/${rel}`;
336
+ if (!(full in snapshot)) {
337
+ throw new Error(
338
+ `導入済みの構成員 ${member.ref} のファイルが発行対象に含まれていません: ${full}。include を確認してください`,
339
+ );
340
+ }
341
+ if (snapshot[full] !== hash) {
342
+ throw new Error(
343
+ `導入済みの構成員 ${member.ref} の内容が、導入時の版と一致しません: ${full}`,
344
+ );
345
+ }
346
+ }
347
+ bundled[member.ref] = installedId;
348
+ }
349
+ return bundled;
350
+ }
351
+
352
+ // マニフェストの libraries から、入れ子の構成員の一覧を取り出す。
353
+ function nestedMembers(manifest) {
354
+ return Object.entries(manifest.libraries || {}).map(([ref, lib]) => ({
355
+ ref,
356
+ version: lib.version,
357
+ repo: lib.repo,
358
+ }));
359
+ }
360
+
361
+ // 作業ツリーの nap_modules にある導入済みコピーを読む。版が一致し、
362
+ // 実物のハッシュがマニフェストと一致する場合だけ使う。キャッシュ扱い。
363
+ function readWorktreeCopy(root, ref, version) {
364
+ const dir = path.join(root, MODULES_DIR, ref);
365
+ const manifestPath = path.join(dir, 'version-manifest.json');
366
+ if (!fs.existsSync(manifestPath)) return null;
367
+ let manifest;
368
+ try {
369
+ manifest = parseManifest(fs.readFileSync(manifestPath));
370
+ } catch {
371
+ return null;
372
+ }
373
+ if (versionIdOf(manifest.files, manifest.libraries) !== version) return null;
374
+ const files = {};
375
+ for (const [rel, hash] of Object.entries(manifest.files)) {
376
+ const filePath = path.join(dir, rel);
377
+ if (!fs.existsSync(filePath)) return null;
378
+ const content = fs.readFileSync(filePath);
379
+ if (sha256Hex(content) !== hash) return null;
380
+ files[rel] = content;
381
+ }
382
+ return { manifest, files, nested: nestedMembers(manifest) };
383
+ }
384
+
385
+ // ローカルのリポジトリから版のツリーを読む。リポジトリはパスまたは URL で指定する。
386
+ function readVersionFromRepo(source, ref, version) {
387
+ const { dir, cleanup } = openLibraryRepo(source);
388
+ try {
389
+ let commit;
390
+ try {
391
+ commit = gitText(['rev-parse', `refs/tags/${tagRefFor(version, null)}^{commit}`], {
392
+ cwd: dir,
393
+ });
394
+ } catch {
395
+ return null;
396
+ }
397
+ const manifestText = readBlobAt(dir, commit, MANIFEST_NAME);
398
+ if (manifestText === null) return null;
399
+ const manifest = parseManifest(manifestText);
400
+ if (versionIdOf(manifest.files, manifest.libraries) !== version) {
401
+ throw new Error(`構成員 ${ref} の版が一致しません: ${source}`);
402
+ }
403
+ const files = {};
404
+ for (const rel of Object.keys(manifest.files)) {
405
+ const content = readBlobAt(dir, commit, rel);
406
+ if (content === null) {
407
+ throw new Error(`構成員 ${ref} のファイルが版のツリーにありません: ${rel}`);
408
+ }
409
+ files[rel] = content;
410
+ }
411
+ return { manifest, files, nested: nestedMembers(manifest) };
412
+ } finally {
413
+ cleanup();
414
+ }
415
+ }
416
+
417
+ // フォーク済みの導入コピーを読む。Git で追跡されているファイルと
418
+ // 未追跡のファイルについて、作業ツリーの現在の内容を使う。無視対象は除く。
419
+ // version-manifest.json は生成物なので含めない。
420
+ function readForkCopy(root, ref) {
421
+ const dir = path.join(root, MODULES_DIR, ref);
422
+ const listed = git(
423
+ ['-C', dir, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'],
424
+ {},
425
+ )
426
+ .toString('utf8')
427
+ .split('\0')
428
+ .filter(Boolean);
429
+ const files = {};
430
+ for (const rel of listed) {
431
+ if (rel === MANIFEST_NAME) continue;
432
+ files[rel] = fs.readFileSync(path.join(dir, rel));
433
+ }
434
+ const manifest = { files: {}, libraries: {} };
435
+ for (const [rel, content] of Object.entries(files)) {
436
+ manifest.files[rel] = sha256Hex(content);
437
+ }
438
+ return {
439
+ manifest,
440
+ files,
441
+ version: versionIdOf(manifest.files, manifest.libraries),
442
+ fork: true,
443
+ };
444
+ }
445
+
446
+ // 構成員1件の内容を用意する。フォーク、作業ツリーの導入済みコピー、
447
+ // ローカルのリポジトリ、ネットワーク取得の順に試す。
448
+ async function obtainMember(root, ref, version, entry, repoHint) {
449
+ if (fs.existsSync(path.join(root, MODULES_DIR, ref, '.git'))) {
450
+ return readForkCopy(root, ref);
451
+ }
452
+ const cached = readWorktreeCopy(root, ref, version);
453
+ if (cached) return cached;
454
+ const source = entry.source || null;
455
+ if (source && (fs.existsSync(source) || isUrlSpec(source))) {
456
+ const fromRepo = readVersionFromRepo(source, ref, version);
457
+ if (fromRepo) return fromRepo;
458
+ }
459
+ const repo = entry.repo || repoHint || ref;
460
+ const { createClient } = await import('../client/nap-client.js');
461
+ const client = createClient({ rawBase: process.env.NAP_RAW_BASE || undefined });
462
+ const result = await client.fetchVersion(repo, version);
463
+ return { manifest: result.manifest, files: result.files, materialized: result.libraries };
464
+ }
465
+
466
+ function placeFiles(ref, manifest, files, state) {
467
+ const base = `${MODULES_DIR}/${ref}`;
468
+ for (const [rel, content] of Object.entries(files)) {
469
+ const full = `${base}/${rel}`;
470
+ if (full in state.snapshot || full in state.blobs) {
471
+ throw new Error(`発行対象に既にあるファイルと競合します: ${full}`);
472
+ }
473
+ state.blobs[full] = content;
474
+ }
475
+ state.blobs[`${base}/version-manifest.json`] = Buffer.from(
476
+ `${JSON.stringify(manifest, null, 2)}\n`,
477
+ 'utf8',
478
+ );
479
+ }
480
+
481
+ // 取得済みのパッケージをそのまま埋め込む。形は fetchVersion の結果と同じ。
482
+ async function placeMaterialized(ref, pkg, state) {
483
+ if (state.versions.has(ref)) {
484
+ if (state.versions.get(ref) !== pkg.versionId) {
485
+ throw new Error(`構成員 ${ref} の版が競合しています。`);
486
+ }
487
+ return;
488
+ }
489
+ state.versions.set(ref, pkg.versionId);
490
+ placeFiles(ref, pkg.manifest, pkg.files, state);
491
+ for (const [nestedRef, nestedPkg] of Object.entries(pkg.libraries || {})) {
492
+ await placeMaterialized(nestedRef, nestedPkg, state);
493
+ }
494
+ }
495
+
496
+ // 構成員を版へ埋め込む。版は、宣言の固定 → --libraries の固定 →
497
+ // 解決元の索引の最新、の順で決める。1件でも用意できない場合は例外にする。
498
+ async function embedMember(root, ref, pinnedVersion, repoHint, spec, state) {
499
+ if (state.versions.has(ref)) {
500
+ if (pinnedVersion && state.versions.get(ref) !== pinnedVersion) {
501
+ throw new Error(`構成員 ${ref} の版が競合しています。`);
502
+ }
503
+ return;
504
+ }
505
+ const entry = spec[ref] || {};
506
+ if (entry.repo !== undefined && !isSourceSpec(entry.repo)) {
507
+ throw new Error(
508
+ `構成員 ${ref} の repo は「<所有者>/<リポジトリ>」で指定してください: ${entry.repo}`,
509
+ );
510
+ }
511
+ let version = pinnedVersion || entry.version || null;
512
+ if (!version) {
513
+ const source = entry.source || entry.repo;
514
+ if (!source) {
515
+ throw new Error(
516
+ `構成員 ${ref} の解決元が --libraries にありません。repo が必要です。`,
517
+ );
518
+ }
519
+ version = resolveLatestVersion(source, ref);
520
+ }
521
+ const pkg = await obtainMember(root, ref, version, entry, repoHint);
522
+ state.versions.set(ref, pkg.version || version);
523
+ if (pkg.fork) state.forks.add(ref);
524
+ placeFiles(ref, pkg.manifest, pkg.files, state);
525
+ if (pkg.materialized) {
526
+ for (const [nestedRef, nestedPkg] of Object.entries(pkg.materialized)) {
527
+ await placeMaterialized(nestedRef, nestedPkg, state);
528
+ }
529
+ } else {
530
+ for (const nested of pkg.nested || []) {
531
+ await embedMember(root, nested.ref, nested.version, nested.repo, spec, state);
532
+ }
533
+ }
534
+ }
535
+
536
+ // 標準入力で y/n を尋ねる。1行読む。EOF の場合は no として扱う。
537
+ function askYesNo(question) {
538
+ process.stdout.write(question);
539
+ return new Promise((resolve) => {
540
+ const stdin = process.stdin;
541
+ let buffer = '';
542
+ let settled = false;
543
+ const finish = (line) => {
544
+ if (settled) return;
545
+ settled = true;
546
+ stdin.pause();
547
+ resolve(/^y(es)?$/i.test((line || '').trim()));
548
+ };
549
+ stdin.on('data', (chunk) => {
550
+ buffer += chunk;
551
+ const at = buffer.indexOf('\n');
552
+ if (at >= 0) finish(buffer.slice(0, at));
553
+ });
554
+ stdin.on('end', () => finish(buffer));
555
+ stdin.on('error', () => finish(''));
556
+ stdin.resume();
557
+ });
558
+ }
559
+
560
+ // 指定したリポジトリの HEAD を発行した場合の版IDを、発行せずに計算する。
561
+ async function computeVersionIdAtHead(root, options) {
562
+ const commit = gitText(['rev-parse', 'HEAD^{commit}'], { cwd: root });
563
+ const declarationText = readBlobAt(root, commit, DECLARATION_FILE);
564
+ if (declarationText === null) {
565
+ throw new Error(`対象コミットにパッケージの宣言 ${DECLARATION_FILE} がありません。`);
566
+ }
567
+ const declaration = parseDeclaration(declarationText.toString('utf8'));
568
+ const { files } = snapshotFiles(root, commit, declaration.include);
569
+ const bundled = readInstalledMembers(root, commit, declaration, files);
570
+ const state = {
571
+ blobs: {},
572
+ versions: new Map(Object.entries(bundled)),
573
+ snapshot: files,
574
+ forks: new Set(),
575
+ };
576
+ if (declaration.composition) {
577
+ const spec = readLibrariesSpec(options.libraries);
578
+ for (const member of declaration.composition) {
579
+ if (state.versions.has(member.ref)) continue;
580
+ await embedMember(root, member.ref, member.version, null, spec, state);
581
+ }
582
+ }
583
+ for (const [rel, content] of Object.entries(state.blobs)) {
584
+ files[rel] = sha256Hex(content);
585
+ }
586
+ return versionIdOf(files, {});
587
+ }
588
+
589
+ // 変更のあるフォークの一覧を返す。変更があるとは、未コミットの変更があるか、
590
+ // HEAD の内容がまだ発行されていないこと。
591
+ async function changedForks(root, embedState, options) {
592
+ const changed = [];
593
+ for (const ref of embedState.forks) {
594
+ const dir = path.join(root, MODULES_DIR, ref);
595
+ const status = gitText(['-C', dir, 'status', '--porcelain'], {});
596
+ if (status.trim().length > 0) {
597
+ changed.push(ref);
598
+ continue;
599
+ }
600
+ let unpublished;
601
+ try {
602
+ const headId = await computeVersionIdAtHead(dir, options);
603
+ const label = options.test !== undefined ? validateLabel(options.test) : null;
604
+ unpublished = !refExists(dir, `refs/tags/${tagRefFor(headId, label)}`);
605
+ } catch {
606
+ unpublished = true;
607
+ }
608
+ if (unpublished) changed.push(ref);
609
+ }
610
+ return changed;
611
+ }
612
+
613
+ // 変更のあるフォークがあれば確認し、了承されたら一緒に発行する。
614
+ // フォーク側に未コミットの変更があれば、先にコミットする。
615
+ async function publishChangedForks(root, embedState, options) {
616
+ const changed = await changedForks(root, embedState, options);
617
+ if (changed.length === 0) return;
618
+ console.log(`変更のあるフォーク: ${changed.join(', ')}`);
619
+ const yes = await askYesNo('フォークの変更も一緒に発行しますか? [y/N] ');
620
+ if (!yes) {
621
+ console.log('フォークは発行しませんでした。内容はこの版に含まれます。');
622
+ return;
623
+ }
624
+ for (const ref of changed) {
625
+ const dir = path.join(root, MODULES_DIR, ref);
626
+ const status = gitText(['-C', dir, 'status', '--porcelain'], {});
627
+ if (status.trim().length > 0) {
628
+ git(['-C', dir, 'add', '-A'], {});
629
+ git(['-C', dir, 'commit', '--quiet', '-m', 'fork: changes'], { env: COMMITTER_ENV });
630
+ }
631
+ console.log(`フォークを発行します: ${ref}`);
632
+ await publish(dir, { ...options, skipForks: true, message: undefined });
633
+ }
634
+ }
635
+
636
+ async function publish(repoSpec, options = {}) {
637
+ const remote = options.remote || 'origin';
638
+ const chunkSize = options.chunkSize || DEFAULT_CHUNK_SIZE;
639
+ const testLabel = options.test !== undefined ? validateLabel(options.test) : null;
640
+ if (!Number.isInteger(chunkSize) || chunkSize < 1) {
641
+ throw new Error(`チャンクサイズが不正です: ${chunkSize}`);
642
+ }
643
+ const root = resolveRepoRoot(repoSpec);
644
+ const commit = gitText(['rev-parse', `${options.ref || 'HEAD'}^{commit}`], { cwd: root });
645
+
646
+ const declarationText = readBlobAt(root, commit, DECLARATION_FILE);
647
+ if (declarationText === null) {
648
+ throw new Error(`対象コミットにパッケージの宣言 ${DECLARATION_FILE} がありません。`);
649
+ }
650
+ const declaration = parseDeclaration(declarationText.toString('utf8'));
651
+
652
+ const { files, objects } = snapshotFiles(root, commit, declaration.include);
653
+ if (Object.keys(files).length === 0) {
654
+ throw new Error(`対象ファイルが1つもありません: ${declaration.name}`);
655
+ }
656
+
657
+ // 構成員は版へ埋め込む。マニフェストの libraries には入れない。
658
+ // コミット内に導入済みのものは実物を照合して使い、残りは固定された版を
659
+ // 取り寄せる。失敗したら、自分の版も作らずに止める。
660
+ const bundled = readInstalledMembers(root, commit, declaration, files);
661
+ const embedState = { blobs: {}, versions: new Map(Object.entries(bundled)), snapshot: files, forks: new Set() };
662
+ if (declaration.composition) {
663
+ const spec = readLibrariesSpec(options.libraries);
664
+ for (const member of declaration.composition) {
665
+ if (embedState.versions.has(member.ref)) continue;
666
+ await embedMember(root, member.ref, member.version, null, spec, embedState);
667
+ }
668
+ }
669
+ for (const [rel, content] of Object.entries(embedState.blobs)) {
670
+ files[rel] = sha256Hex(content);
671
+ }
672
+ const libraries = {};
673
+
674
+ const ownPackageId = packageIdOf(files);
675
+ const id = versionIdOf(files, libraries);
676
+
677
+ console.log(`対象コミット: ${commit}`);
678
+ console.log(`パッケージ: ${declaration.name} / ファイル: ${Object.keys(files).length} 点`);
679
+ console.log(`パッケージID: ${ownPackageId}`);
680
+ for (const [ref, version] of Object.entries(bundled)) {
681
+ console.log(`導入済みの構成員: ${ref} = ${version}`);
682
+ }
683
+ for (const [ref, version] of embedState.versions) {
684
+ if (ref in bundled) continue;
685
+ console.log(
686
+ embedState.forks.has(ref)
687
+ ? `フォークの構成員: ${ref} = ${version}`
688
+ : `取り寄せた構成員: ${ref} = ${version}`,
689
+ );
690
+ }
691
+ console.log(`版ID: ${id}`);
692
+
693
+ if (!options.skipForks && embedState.forks.size > 0) {
694
+ await publishChangedForks(root, embedState, options);
695
+ }
696
+
697
+ const tagRef = tagRefFor(id, testLabel);
698
+ if (testLabel) console.log(`種別: 開発中の版 TestVersion。ラベル: ${testLabel}`);
699
+
700
+ fetchBranch(root, remote, VERSIONS_BRANCH);
701
+ fetchBranch(root, remote, INDEX_BRANCH);
702
+
703
+ // 版コードは版IDから決める。
704
+ const { entries: indexEntries } = readIndexAt(root, `refs/heads/${INDEX_BRANCH}`);
705
+ const versionCode = deriveVersionCode(
706
+ id,
707
+ new Set(indexEntries.map((entry) => entry.code).filter(Boolean)),
708
+ );
709
+
710
+ const existing = remoteTagTarget(root, remote, tagRef);
711
+ if (existing) {
712
+ if (!options.noPush) pushSourceBranch(root, remote, options.ref, commit);
713
+ // タグは既にある=版は作らない。索引にだけ欠けがあれば補う。
714
+ const added = appendIndex(
715
+ root,
716
+ remote,
717
+ { id, commit: existing, at: new Date().toISOString(), tag: testLabel, source: commit, code: versionCode },
718
+ chunkSize,
719
+ !options.noPush,
720
+ );
721
+ if (added) console.log(`版コード: ${versionCode}`);
722
+ console.log(added ? 'no-op: タグは既存。索引にだけ追加しました' : 'no-op: 登録済みのため何もしませんでした');
723
+ return { id, packageId: ownPackageId, tag: testLabel, noop: true };
724
+ }
725
+
726
+ // 専用コミット:パッケージのファイル+マニフェスト。作業ツリーは触らない。
727
+ const manifest = manifestBuffer(files, libraries);
728
+ const entries = Object.entries(objects).map(([rel, o]) => ({ rel, oid: o.oid, mode: o.mode }));
729
+ const tree = buildTree(root, null, entries, { [MANIFEST_NAME]: manifest, ...embedState.blobs });
730
+ const versionsRef = `refs/heads/${VERSIONS_BRANCH}`;
731
+ const versionsTip = refExists(root, versionsRef)
732
+ ? gitText(['rev-parse', versionsRef], { cwd: root })
733
+ : null;
734
+ const labelSuffix = testLabel ? `-${testLabel}` : '';
735
+ const description = typeof options.message === 'string' ? options.message.trim() : '';
736
+ const versionMessage = `version ${id.slice(0, 12)}${labelSuffix}\n${description ? `\n${description}\n` : ''}`;
737
+ const versionCommit = commitTree(root, tree, versionsTip ? [versionsTip] : [], versionMessage);
738
+ git(['update-ref', versionsRef, versionCommit], { cwd: root });
739
+ git(['update-ref', `refs/tags/${tagRef}`, versionCommit], { cwd: root });
740
+ if (!options.noPush) {
741
+ pushSourceBranch(root, remote, options.ref, commit);
742
+ git(
743
+ [
744
+ 'push',
745
+ '--quiet',
746
+ remote,
747
+ `${versionsRef}:${versionsRef}`,
748
+ `refs/tags/${tagRef}:refs/tags/${tagRef}`,
749
+ ],
750
+ { cwd: root },
751
+ );
752
+ }
753
+
754
+ appendIndex(
755
+ root,
756
+ remote,
757
+ { id, commit: versionCommit, at: new Date().toISOString(), tag: testLabel, source: commit, code: versionCode },
758
+ chunkSize,
759
+ !options.noPush,
760
+ );
761
+
762
+ console.log(`版コード: ${versionCode}`);
763
+ console.log(`専用コミット: ${versionCommit}`);
764
+ console.log(
765
+ options.noPush
766
+ ? `タグ ${tagRef} を作りました。--no-push のため push はしていません`
767
+ : `タグ ${tagRef} を ${remote} へ push しました`,
768
+ );
769
+ return { id, packageId: ownPackageId, tag: testLabel, noop: false };
770
+ }
771
+
772
+ module.exports = { publish, resolveRepoRoot, askYesNo };