@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/README.md +141 -2
- package/cli.js +157 -0
- package/client/README.md +135 -0
- package/client/nap-client.js +613 -0
- package/client/nap-page.js +187 -0
- package/client/package.json +1 -0
- package/lib/core.js +166 -0
- package/lib/declaration.js +89 -0
- package/lib/fork.js +147 -0
- package/lib/get.js +209 -0
- package/lib/git.js +39 -0
- package/lib/glob.js +59 -0
- package/lib/init.js +53 -0
- package/lib/inspect.js +120 -0
- package/lib/publish.js +772 -0
- package/lib/read.js +68 -0
- package/lib/remove.js +70 -0
- package/lib/search.js +92 -0
- package/package.json +17 -4
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// NAP-Page: ページを NAP の版で管理するためのライブラリ。
|
|
2
|
+
//
|
|
3
|
+
// 使い方:
|
|
4
|
+
// import { createNapPage } from './nap-page.js';
|
|
5
|
+
// const page = createNapPage({
|
|
6
|
+
// source: '<所有者>/<リポジトリ>',
|
|
7
|
+
// rawBase: 'https://cdn.nyaitter.jp', // 任意。既定はこの CDN
|
|
8
|
+
// currentVersionId: '<今の版ID>', // 任意
|
|
9
|
+
// });
|
|
10
|
+
// const { hasUpdate, latest } = await page.checkUpdate();
|
|
11
|
+
// if (hasUpdate) {
|
|
12
|
+
// const result = await page.fetchVersion(latest.id);
|
|
13
|
+
// await page.store(result); // Cache Storage へ検証済みのファイルを入れる
|
|
14
|
+
// await page.activate(latest.id); // 旧い版のキャッシュを消す
|
|
15
|
+
// }
|
|
16
|
+
//
|
|
17
|
+
// 取得と照合は nap-client が担う。このライブラリは、版の発見・更新確認と、
|
|
18
|
+
// ブラウザの Cache Storage への格納を担当する。
|
|
19
|
+
|
|
20
|
+
import { createClient } from './nap-client.js';
|
|
21
|
+
|
|
22
|
+
const MIME_TYPES = {
|
|
23
|
+
'.html': 'text/html; charset=utf-8',
|
|
24
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
25
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
26
|
+
'.css': 'text/css; charset=utf-8',
|
|
27
|
+
'.json': 'application/json; charset=utf-8',
|
|
28
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
29
|
+
'.svg': 'image/svg+xml',
|
|
30
|
+
'.png': 'image/png',
|
|
31
|
+
'.jpg': 'image/jpeg',
|
|
32
|
+
'.jpeg': 'image/jpeg',
|
|
33
|
+
'.gif': 'image/gif',
|
|
34
|
+
'.webp': 'image/webp',
|
|
35
|
+
'.ico': 'image/x-icon',
|
|
36
|
+
'.wasm': 'application/wasm',
|
|
37
|
+
'.gz': 'application/gzip',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function typeFor(filePath) {
|
|
41
|
+
const dot = filePath.lastIndexOf('.');
|
|
42
|
+
const ext = dot >= 0 ? filePath.slice(dot).toLowerCase() : '';
|
|
43
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 取得結果を、Cache Storage に置くパスの組へ平らにする。本体と構成員が対象。
|
|
47
|
+
function flattenInto(out, prefix, result) {
|
|
48
|
+
for (const [rel, bytes] of Object.entries(result.files || {})) {
|
|
49
|
+
out[prefix + rel] = bytes;
|
|
50
|
+
}
|
|
51
|
+
for (const [ref, lib] of Object.entries(result.libraries || {})) {
|
|
52
|
+
flattenInto(out, `${prefix}nap_modules/${ref}/`, lib);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createNapPage(options = {}) {
|
|
57
|
+
const { source, rawBase = 'https://cdn.nyaitter.jp', client: givenClient, fetch: fetchImpl, cache } = options;
|
|
58
|
+
if (!source) {
|
|
59
|
+
throw new Error('source が必要です。<所有者>/<リポジトリ> の形で指定してください。');
|
|
60
|
+
}
|
|
61
|
+
const client = givenClient || createClient({ rawBase, fetch: fetchImpl, cache });
|
|
62
|
+
let currentVersionId = options.currentVersionId || null;
|
|
63
|
+
|
|
64
|
+
// 版の一覧。索引の順。各要素は { id, code, tag, commit, source, at, description }。
|
|
65
|
+
async function versions() {
|
|
66
|
+
const { entries } = await client.listVersions(source);
|
|
67
|
+
return entries;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 最新の正式版。なければ null。
|
|
71
|
+
async function latest() {
|
|
72
|
+
return client.latestVersion(source);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 現在の版と最新を比べる。
|
|
76
|
+
async function checkUpdate() {
|
|
77
|
+
const newest = await latest();
|
|
78
|
+
return {
|
|
79
|
+
current: currentVersionId,
|
|
80
|
+
latest: newest,
|
|
81
|
+
hasUpdate: Boolean(newest && newest.id !== currentVersionId),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 版を取得して照合する。ref は版ID・版ID-ラベル・版コードのいずれか。
|
|
86
|
+
async function fetchVersion(ref, options) {
|
|
87
|
+
return client.fetchVersion(source, ref, options);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 版の説明。配信サーバーの API から取得する。取得できなければ空を返す。
|
|
91
|
+
async function describe(versionId) {
|
|
92
|
+
try {
|
|
93
|
+
const fetcher = fetchImpl || globalThis.fetch;
|
|
94
|
+
if (!fetcher) return '';
|
|
95
|
+
const response = await fetcher(`${rawBase}/api/repos/${source}/versions/${versionId}`);
|
|
96
|
+
if (!response || !response.ok) return '';
|
|
97
|
+
const data = await response.json();
|
|
98
|
+
return String((data && data.description) || '').trim();
|
|
99
|
+
} catch (_) {
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function fetchLatest(options) {
|
|
105
|
+
const newest = await latest();
|
|
106
|
+
if (!newest) {
|
|
107
|
+
throw new Error(`正式版はまだありません: ${source}`);
|
|
108
|
+
}
|
|
109
|
+
return client.fetchVersion(source, newest.id, options);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function cacheName(versionId) {
|
|
113
|
+
return `nap-page:${String(source).toLowerCase()}:${versionId}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 取得結果を Cache Storage へ格納する。キーは版の中のパスそのまま
|
|
117
|
+
// 構成員は nap_modules/<参照>/ の下。Service Worker からはこの
|
|
118
|
+
// キャッシュを版のファイルとして参照できる。
|
|
119
|
+
async function store(result, options = {}) {
|
|
120
|
+
if (typeof caches === 'undefined') {
|
|
121
|
+
throw new Error('Cache Storage が使えない環境です。');
|
|
122
|
+
}
|
|
123
|
+
const flat = {};
|
|
124
|
+
flattenInto(flat, '', result);
|
|
125
|
+
const storage = await caches.open(cacheName(result.versionId));
|
|
126
|
+
const entries = Object.entries(flat);
|
|
127
|
+
let done = 0;
|
|
128
|
+
for (const [rel, bytes] of entries) {
|
|
129
|
+
await storage.put(
|
|
130
|
+
rel,
|
|
131
|
+
new Response(bytes, { headers: { 'Content-Type': typeFor(rel) } }),
|
|
132
|
+
);
|
|
133
|
+
done += 1;
|
|
134
|
+
if (typeof options.onProgress === 'function') {
|
|
135
|
+
try {
|
|
136
|
+
options.onProgress({ done, total: entries.length });
|
|
137
|
+
} catch (_) {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return cacheName(result.versionId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 格納済みの版IDの一覧。
|
|
144
|
+
async function storedVersions() {
|
|
145
|
+
if (typeof caches === 'undefined') {
|
|
146
|
+
throw new Error('Cache Storage が使えない環境です。');
|
|
147
|
+
}
|
|
148
|
+
const prefix = `nap-page:${String(source).toLowerCase()}:`;
|
|
149
|
+
const keys = await caches.keys();
|
|
150
|
+
return keys
|
|
151
|
+
.filter((key) => key.startsWith(prefix))
|
|
152
|
+
.map((key) => key.slice(prefix.length));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 指定した版を現在にして、他の版のキャッシュを消す。
|
|
156
|
+
async function activate(versionId) {
|
|
157
|
+
if (typeof caches === 'undefined') {
|
|
158
|
+
throw new Error('Cache Storage が使えない環境です。');
|
|
159
|
+
}
|
|
160
|
+
const keep = cacheName(versionId);
|
|
161
|
+
const prefix = `nap-page:${String(source).toLowerCase()}:`;
|
|
162
|
+
for (const key of await caches.keys()) {
|
|
163
|
+
if (key.startsWith(prefix) && key !== keep) {
|
|
164
|
+
await caches.delete(key);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
currentVersionId = versionId;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
versions,
|
|
172
|
+
latest,
|
|
173
|
+
checkUpdate,
|
|
174
|
+
fetchVersion,
|
|
175
|
+
describe,
|
|
176
|
+
fetchLatest,
|
|
177
|
+
store,
|
|
178
|
+
storedVersions,
|
|
179
|
+
activate,
|
|
180
|
+
cacheName,
|
|
181
|
+
get currentVersionId() {
|
|
182
|
+
return currentVersionId;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default { createNapPage };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"description":"NAP のクライアント向け取得ライブラリです。","name":"nap-client","private":true,"type":"module"}
|
package/lib/core.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const MANIFEST_NAME = 'version-manifest.json';
|
|
6
|
+
const VERSIONS_BRANCH = 'nap/versions';
|
|
7
|
+
const INDEX_BRANCH = 'nap/index';
|
|
8
|
+
const ID_PATTERN = /^[0-9a-f]{64}$/;
|
|
9
|
+
const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10
|
+
|
|
11
|
+
// TestVersion のラベル。git の参照名に使える形に限る。
|
|
12
|
+
function validateLabel(label) {
|
|
13
|
+
if (
|
|
14
|
+
typeof label !== 'string' ||
|
|
15
|
+
label.length < 1 ||
|
|
16
|
+
label.length > 32 ||
|
|
17
|
+
!LABEL_PATTERN.test(label) ||
|
|
18
|
+
label.endsWith('.') ||
|
|
19
|
+
label.endsWith('.lock') ||
|
|
20
|
+
label.includes('..')
|
|
21
|
+
) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`ラベルが不正です: ${label}。1〜32文字、英数字で始まり、使える文字は英数字と . _ - 。末尾の . と連続した .. は使えません`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return label;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 「<版ID>」または「<版ID>-<ラベル>」を分解する。版IDは前方一致の短縮でもよい。
|
|
30
|
+
// 16進数には - が含まれないため、最初の - で一意に分かれる。
|
|
31
|
+
function parseVersionRef(ref) {
|
|
32
|
+
if (typeof ref !== 'string' || ref.length === 0) {
|
|
33
|
+
throw new Error(`版の指定が不正です: ${ref}`);
|
|
34
|
+
}
|
|
35
|
+
const dash = ref.indexOf('-');
|
|
36
|
+
const prefix = dash === -1 ? ref : ref.slice(0, dash);
|
|
37
|
+
const tag = dash === -1 ? null : ref.slice(dash + 1);
|
|
38
|
+
if (!/^[0-9a-f]{1,64}$/.test(prefix)) {
|
|
39
|
+
throw new Error(`版の指定が不正です: ${ref}`);
|
|
40
|
+
}
|
|
41
|
+
if (tag !== null) validateLabel(tag);
|
|
42
|
+
return { prefix, tag };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 版を指すタグ名。正式版は nap/<版ID>、TestVersion は nap/<版ID>-<ラベル>。
|
|
46
|
+
function tagRefFor(id, tag) {
|
|
47
|
+
return tag ? `nap/${id}-${tag}` : `nap/${id}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sha256Hex(buffer) {
|
|
51
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function compareBytes(a, b) {
|
|
55
|
+
return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hashLines(lines) {
|
|
59
|
+
const sorted = [...lines].sort(compareBytes);
|
|
60
|
+
return sha256Hex(Buffer.from(`${sorted.join('\n')}\n`, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// パッケージIDは、自ファイルの `F <パス> <sha256>` 行だけから生成する。
|
|
64
|
+
function packageIdOf(files) {
|
|
65
|
+
return hashLines(Object.entries(files).map(([rel, hash]) => `F ${rel} ${hash}`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 版IDは、自ファイルの F 行と、構成員の `P <組織ID>/<名前> <版ID>` 行から生成する。
|
|
69
|
+
// libraries は { "<組織ID>/<名前>": "<版ID>" または { version } } の形で受ける。
|
|
70
|
+
function versionIdOf(files, libraries) {
|
|
71
|
+
const lines = Object.entries(files).map(([rel, hash]) => `F ${rel} ${hash}`);
|
|
72
|
+
for (const [ref, lib] of Object.entries(libraries)) {
|
|
73
|
+
const version = typeof lib === 'string' ? lib : lib.version;
|
|
74
|
+
lines.push(`P ${ref} ${version}`);
|
|
75
|
+
}
|
|
76
|
+
return hashLines(lines);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// マニフェストの中身。版ID自身と日時は入れない。自己参照を避けるため。
|
|
80
|
+
// libraries の各項目は { name, repo, version } を持つ。キーは決定的な並びにする。
|
|
81
|
+
function manifestBuffer(files, libraries) {
|
|
82
|
+
const sortedFiles = {};
|
|
83
|
+
for (const rel of Object.keys(files).sort(compareBytes)) sortedFiles[rel] = files[rel];
|
|
84
|
+
const sortedLibs = {};
|
|
85
|
+
for (const ref of Object.keys(libraries).sort(compareBytes)) {
|
|
86
|
+
const lib = libraries[ref];
|
|
87
|
+
sortedLibs[ref] = { name: ref, repo: lib.repo, version: lib.version };
|
|
88
|
+
}
|
|
89
|
+
return Buffer.from(
|
|
90
|
+
`${JSON.stringify({ files: sortedFiles, libraries: sortedLibs }, null, 2)}\n`,
|
|
91
|
+
'utf8',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const CODE_PATTERN = /^[0-9a-z]{3,}$/;
|
|
96
|
+
const CODE_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
97
|
+
|
|
98
|
+
// 版コードの形かどうか。版IDの完全な形とは重ならない。
|
|
99
|
+
function isVersionCode(text) {
|
|
100
|
+
if (typeof text !== 'string' || !CODE_PATTERN.test(text)) return false;
|
|
101
|
+
return !/^[0-9a-f]{64}(-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(text);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 版コードは版IDから決める。同じ版にはいつも同じコードが割り当たる。
|
|
105
|
+
// そのコードが使われていたら、桁を延ばして決める。
|
|
106
|
+
function deriveVersionCode(versionId, used) {
|
|
107
|
+
const stream = BigInt(`0x${versionId}`).toString(CODE_ALPHABET.length);
|
|
108
|
+
for (let length = 3; length <= stream.length; length++) {
|
|
109
|
+
const code = stream.slice(0, length);
|
|
110
|
+
if (!used.has(code)) return code;
|
|
111
|
+
}
|
|
112
|
+
throw new Error('版コードを生成できませんでした。');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ソースの指定は「<所有者>/<リポジトリ>」だけ。GitHub 前提。
|
|
116
|
+
function isSourceSpec(source) {
|
|
117
|
+
if (typeof source !== 'string') return false;
|
|
118
|
+
const text = source.trim();
|
|
119
|
+
if (!text) return false;
|
|
120
|
+
const parts = text.split('/');
|
|
121
|
+
return parts.length === 2 && parts.every((part) => part.length > 0 && !/[\s:@]/.test(part));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseManifest(buffer) {
|
|
125
|
+
let manifest;
|
|
126
|
+
try {
|
|
127
|
+
manifest = JSON.parse(buffer.toString('utf8'));
|
|
128
|
+
} catch {
|
|
129
|
+
throw new Error('マニフェストが JSON として読めません。');
|
|
130
|
+
}
|
|
131
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
132
|
+
throw new Error('マニフェストの形式が不正です。');
|
|
133
|
+
}
|
|
134
|
+
const files = manifest.files || {};
|
|
135
|
+
const libraries = manifest.libraries || {};
|
|
136
|
+
for (const [rel, hash] of Object.entries(files)) {
|
|
137
|
+
if (!ID_PATTERN.test(hash)) {
|
|
138
|
+
throw new Error(`マニフェストのファイルハッシュが不正です: ${rel}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const [ref, lib] of Object.entries(libraries)) {
|
|
142
|
+
if (!lib || lib.name !== ref || !isSourceSpec(lib.repo) || !ID_PATTERN.test(lib.version || '')) {
|
|
143
|
+
throw new Error(`マニフェストの構成員が不正です: ${ref}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { files, libraries };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
MANIFEST_NAME,
|
|
151
|
+
VERSIONS_BRANCH,
|
|
152
|
+
INDEX_BRANCH,
|
|
153
|
+
ID_PATTERN,
|
|
154
|
+
validateLabel,
|
|
155
|
+
parseVersionRef,
|
|
156
|
+
tagRefFor,
|
|
157
|
+
sha256Hex,
|
|
158
|
+
compareBytes,
|
|
159
|
+
packageIdOf,
|
|
160
|
+
versionIdOf,
|
|
161
|
+
manifestBuffer,
|
|
162
|
+
parseManifest,
|
|
163
|
+
isSourceSpec,
|
|
164
|
+
isVersionCode,
|
|
165
|
+
deriveVersionCode,
|
|
166
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DECLARATION_FILE = 'nap.json';
|
|
4
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
5
|
+
const ID_PATTERN = /^[0-9a-f]{64}$/;
|
|
6
|
+
|
|
7
|
+
function parseComposition(raw, where) {
|
|
8
|
+
if (raw === undefined) return null;
|
|
9
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
10
|
+
throw new Error(`${where}.composition には構成員が1件以上必要です。`);
|
|
11
|
+
}
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
return raw.map((entry, index) => {
|
|
14
|
+
const at = `${where}.composition[${index}]`;
|
|
15
|
+
const ref = typeof entry === 'string' ? entry : entry && entry.name;
|
|
16
|
+
const version = typeof entry === 'string' ? null : entry && entry.version;
|
|
17
|
+
// 構成員の参照は「<組織ID>/<パッケージ名>」で、組織IDは省略できない。
|
|
18
|
+
const slash = typeof ref === 'string' ? ref.indexOf('/') : -1;
|
|
19
|
+
const org = slash > 0 ? ref.slice(0, slash) : '';
|
|
20
|
+
const name = slash > 0 ? ref.slice(slash + 1) : '';
|
|
21
|
+
if (!NAME_PATTERN.test(org) || !NAME_PATTERN.test(name)) {
|
|
22
|
+
throw new Error(`${at} のパッケージ参照が不正です。<組織ID>/<パッケージ名> の形で書いてください。`);
|
|
23
|
+
}
|
|
24
|
+
if (seen.has(ref)) {
|
|
25
|
+
throw new Error(`構成員が重複しています: ${ref}`);
|
|
26
|
+
}
|
|
27
|
+
seen.add(ref);
|
|
28
|
+
if (version !== null && version !== undefined) {
|
|
29
|
+
if (typeof version !== 'string' || !ID_PATTERN.test(version)) {
|
|
30
|
+
throw new Error(`${at}.version は版IDで書いてください。版IDは16進数64桁です。`);
|
|
31
|
+
}
|
|
32
|
+
return { ref, org, name, version };
|
|
33
|
+
}
|
|
34
|
+
return { ref, org, name, version: null };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 宣言は1リポジトリ1パッケージのフラットな形式だけを受ける。
|
|
39
|
+
// 旧形式の packages 配列は互換読みせず、明確なエラーにする。
|
|
40
|
+
function parseDeclaration(text) {
|
|
41
|
+
let doc;
|
|
42
|
+
try {
|
|
43
|
+
doc = JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`${DECLARATION_FILE} が JSON として読めません。`);
|
|
46
|
+
}
|
|
47
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
48
|
+
throw new Error(`${DECLARATION_FILE} の形式が不正です。`);
|
|
49
|
+
}
|
|
50
|
+
if ('packages' in doc) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${DECLARATION_FILE} の旧形式 packages 配列は使えません。` +
|
|
53
|
+
'name/include/composition をトップレベルに書いてください。',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (typeof doc.name !== 'string' || !NAME_PATTERN.test(doc.name)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${DECLARATION_FILE}.name が不正です。小文字の英数字と . _ - で始まる名前を使ってください。`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
!Array.isArray(doc.include) ||
|
|
63
|
+
doc.include.length === 0 ||
|
|
64
|
+
doc.include.some((pattern) => typeof pattern !== 'string' || pattern.length === 0)
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(`${DECLARATION_FILE}.include には対象ファイルの指定が1件以上必要です。`);
|
|
67
|
+
}
|
|
68
|
+
for (const pattern of doc.include) {
|
|
69
|
+
const body = pattern.startsWith('!') ? pattern.slice(1) : pattern;
|
|
70
|
+
if (body.startsWith('/') || body.split('/').includes('..')) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${DECLARATION_FILE}.include の指定がリポジトリの外を指しています: ${pattern}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const composition = parseComposition(doc.composition, DECLARATION_FILE);
|
|
77
|
+
if (composition) {
|
|
78
|
+
for (const member of composition) {
|
|
79
|
+
if (member.name === doc.name) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`構成員に自分自身は書けません。宣言したパッケージは自動で版に含まれます: ${member.ref}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { name: doc.name, include: doc.include, composition };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { DECLARATION_FILE, NAME_PATTERN, parseDeclaration };
|
package/lib/fork.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// nap fork: パッケージのフォークを nap_modules へ置く。フォークはリポジトリの複製。
|
|
4
|
+
// パッケージ自体が Git リポジトリであることを利用し、clone した作業ツリーを
|
|
5
|
+
// そのまま導入済みの場所に置く。導入済みなら置き換え、未導入なら導入になる。
|
|
6
|
+
// 以後、その場所は通常の Git リポジトリとして編集でき、nap get では上書きされず、
|
|
7
|
+
// nap publish では現在の内容が版へ埋め込まれる。
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const { execFileSync } = require('node:child_process');
|
|
12
|
+
const { sha256Hex } = require('./core');
|
|
13
|
+
const { parseGetTarget, dependencyRefFor, recordDependency, MODULES_DIR } = require('./get');
|
|
14
|
+
const { readIndexAt } = require('./read');
|
|
15
|
+
const { INDEX_BRANCH } = require('./core');
|
|
16
|
+
|
|
17
|
+
function git(args, options = {}) {
|
|
18
|
+
return execFileSync('git', args, { encoding: 'utf8', ...options });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ソースから clone 先の URL を作る。ソースは「<所有者>/<リポジトリ>」の形。
|
|
22
|
+
function cloneUrlFor(repo) {
|
|
23
|
+
return `https://github.com/${repo}.git`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isForkDir(dir) {
|
|
27
|
+
return fs.existsSync(path.join(dir, '.git'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 導入済みコピーが導入時の内容から変わっているか。フォークではないものが対象。
|
|
31
|
+
function installedDiverged(dir) {
|
|
32
|
+
const manifestPath = path.join(dir, 'version-manifest.json');
|
|
33
|
+
if (!fs.existsSync(manifestPath)) return true;
|
|
34
|
+
let manifest;
|
|
35
|
+
try {
|
|
36
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
for (const [rel, hash] of Object.entries(manifest.files || {})) {
|
|
41
|
+
const filePath = path.join(dir, rel);
|
|
42
|
+
if (!fs.existsSync(filePath)) return true;
|
|
43
|
+
if (sha256Hex(fs.readFileSync(filePath)) !== hash) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fork(specText) {
|
|
49
|
+
const cwd = process.cwd();
|
|
50
|
+
const declarationPath = path.join(cwd, 'nap.json');
|
|
51
|
+
if (!fs.existsSync(declarationPath)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'nap.json がありません。フォークは、プロジェクトのフォルダで実行してください。',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const { repo, ref } = parseGetTarget(specText);
|
|
57
|
+
const depRef = dependencyRefFor(repo);
|
|
58
|
+
const targetDir = path.join(cwd, MODULES_DIR, depRef);
|
|
59
|
+
if (isForkDir(targetDir)) {
|
|
60
|
+
throw new Error(`既にフォークされています: ${depRef}。場所: ${targetDir}`);
|
|
61
|
+
}
|
|
62
|
+
if (fs.existsSync(targetDir) && installedDiverged(targetDir)) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'導入済みのコピーが変更されています。フォークで置き換えると変更が失われるため止めました。',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 版を確定する。取得ライブラリで実在を確かめる。
|
|
69
|
+
const { createClient } = await import('../client/nap-client.js');
|
|
70
|
+
const client = createClient({ rawBase: process.env.NAP_RAW_BASE || undefined });
|
|
71
|
+
let versionId;
|
|
72
|
+
let label = null;
|
|
73
|
+
if (ref === null) {
|
|
74
|
+
const latest = await client.latestVersion(repo);
|
|
75
|
+
if (!latest) throw new Error(`正式版はまだありません: ${repo}`);
|
|
76
|
+
versionId = latest.id;
|
|
77
|
+
} else {
|
|
78
|
+
const resolved = await client.fetchVersion(repo, ref);
|
|
79
|
+
versionId = resolved.versionId;
|
|
80
|
+
label = resolved.tag || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const tmpDir = `${targetDir}.fork-tmp`;
|
|
84
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
85
|
+
try {
|
|
86
|
+
git(['clone', '--quiet', cloneUrlFor(repo), tmpDir], { stdio: 'inherit' });
|
|
87
|
+
if (ref !== null) {
|
|
88
|
+
// 基点は、版の出典コミットか、版のツリーにする。出典コミットは辿れる場合。
|
|
89
|
+
const tagRef = label ? `nap/${versionId}-${label}` : `nap/${versionId}`;
|
|
90
|
+
// 出典は索引に記録されている。出典は発行の対象だったコミット。
|
|
91
|
+
// clone した側の索引から引き、コミットが手元にあればそれを基点にする。
|
|
92
|
+
const { entries } = readIndexAt(tmpDir, `refs/remotes/origin/${INDEX_BRANCH}`);
|
|
93
|
+
const hit = entries.find(
|
|
94
|
+
(e) => e.id === versionId && (e.tag || null) === (label || null),
|
|
95
|
+
);
|
|
96
|
+
let source = null;
|
|
97
|
+
if (hit && hit.source) {
|
|
98
|
+
try {
|
|
99
|
+
git(['rev-parse', '--verify', '--quiet', `${hit.source}^{commit}`], {
|
|
100
|
+
cwd: tmpDir,
|
|
101
|
+
});
|
|
102
|
+
source = hit.source;
|
|
103
|
+
} catch {
|
|
104
|
+
source = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
git(['checkout', '--quiet', source || `refs/tags/${tagRef}`], { cwd: tmpDir });
|
|
108
|
+
if (!fs.existsSync(path.join(tmpDir, 'nap.json'))) {
|
|
109
|
+
// 宣言が版のツリーに無い場合は、既定ブランチのものを持ってくる。
|
|
110
|
+
let declaration;
|
|
111
|
+
try {
|
|
112
|
+
declaration = git(['show', 'origin/HEAD:nap.json'], { cwd: tmpDir });
|
|
113
|
+
} catch {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'パッケージの宣言 nap.json が、版のツリーにも既定ブランチにもありません。',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
fs.writeFileSync(path.join(tmpDir, 'nap.json'), declaration);
|
|
119
|
+
git(['add', 'nap.json'], { cwd: tmpDir });
|
|
120
|
+
git(['commit', '--quiet', '-m', 'fork: add nap.json from default branch'], {
|
|
121
|
+
cwd: tmpDir,
|
|
122
|
+
env: {
|
|
123
|
+
...process.env,
|
|
124
|
+
GIT_AUTHOR_NAME: 'nap',
|
|
125
|
+
GIT_AUTHOR_EMAIL: 'nap@localhost',
|
|
126
|
+
GIT_COMMITTER_NAME: 'nap',
|
|
127
|
+
GIT_COMMITTER_EMAIL: 'nap@localhost',
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
|
133
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
134
|
+
fs.renameSync(tmpDir, targetDir);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const text = fs.readFileSync(declarationPath, 'utf8');
|
|
141
|
+
fs.writeFileSync(declarationPath, recordDependency(text, depRef, versionId));
|
|
142
|
+
console.log(`フォーク: ${depRef} = ${versionId}`);
|
|
143
|
+
console.log(`場所: ${targetDir}`);
|
|
144
|
+
console.log('origin は元のリポジトリです。自分の場所へ出すときは差し替えてください。');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { fork, cloneUrlFor, isForkDir };
|