@vryzel/file-next-cli 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 file-next contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @vryzel/file-next-cli
2
+
3
+ CLI for file-next.
4
+
5
+ ```bash
6
+ pnpm add -g @vryzel/file-next-cli
7
+ # or: pnpm dlx @vryzel/file-next-cli
8
+ file-next migrate --adapter=sqlite
9
+ file-next reconcile --tenant=<id> --dry-run
10
+ file-next doctor
11
+ ```
12
+
13
+ Public on npmjs. See the repository README.
package/dist/bin.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/bin.js ADDED
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'util';
3
+ import { asTenantId, createPostgresStore, createSqliteStore, parseFileSystemConfig, createMemoryFileSystem, createFileSystem } from '@vryzel/file-next';
4
+ import { createWriteThrough } from '@vryzel/file-next/sync';
5
+
6
+ function parseMigrateArgs(argv) {
7
+ let parsed;
8
+ try {
9
+ parsed = parseArgs({
10
+ args: argv,
11
+ options: {
12
+ adapter: { type: "string", short: "a" }
13
+ },
14
+ strict: true,
15
+ allowPositionals: false
16
+ });
17
+ } catch (err) {
18
+ return { error: err.message };
19
+ }
20
+ const adapter = parsed.values.adapter;
21
+ if (adapter !== "postgres" && adapter !== "sqlite") {
22
+ return { error: "--adapter must be either 'postgres' or 'sqlite'" };
23
+ }
24
+ return { adapter };
25
+ }
26
+ async function runMigrate(options, hooks = {
27
+ // Default no-op migrator so the CLI can be smoke-tested without
28
+ // a real DB. The real adapter wiring lives in the consumer's
29
+ // app where drizzle-orm is a runtime dep.
30
+ resolveMigrator: async () => ({
31
+ listPending: async () => [],
32
+ apply: async () => []
33
+ })
34
+ }) {
35
+ const migrator = await hooks.resolveMigrator(options.adapter);
36
+ const pending = await migrator.listPending();
37
+ const applied = await migrator.apply();
38
+ return { applied, pending };
39
+ }
40
+ function parseReconcileArgs(argv) {
41
+ let parsed;
42
+ try {
43
+ parsed = parseArgs({
44
+ args: argv,
45
+ options: {
46
+ tenant: { type: "string", short: "t" },
47
+ "dry-run": { type: "boolean", default: false }
48
+ },
49
+ strict: true,
50
+ allowPositionals: false
51
+ });
52
+ } catch (err) {
53
+ return { error: err.message };
54
+ }
55
+ if (typeof parsed.values.tenant !== "string" || parsed.values.tenant.length === 0) {
56
+ return { error: "--tenant is required" };
57
+ }
58
+ return {
59
+ tenant: parsed.values.tenant,
60
+ dryRun: parsed.values["dry-run"] === true
61
+ };
62
+ }
63
+ async function runReconcile(options, hooks = {
64
+ runSync: async () => ({
65
+ missingInS3: [],
66
+ orphansInS3: [],
67
+ fixedCount: 0
68
+ })
69
+ }) {
70
+ const drift = await hooks.runSync({
71
+ tenant: options.tenant,
72
+ dryRun: options.dryRun
73
+ });
74
+ const hadDrift = drift.missingInS3.length + drift.orphansInS3.length > 0;
75
+ return { drift, hadDrift };
76
+ }
77
+
78
+ // src/doctor.ts
79
+ var REQUIRED_VARS = ["FILE_NEXT_BUCKET", "FILE_NEXT_REGION", "FILE_NEXT_PROVIDER"];
80
+ var POSTGRES_VARS = ["FILE_NEXT_PG_HOST", "FILE_NEXT_PG_USER", "FILE_NEXT_PG_DATABASE"];
81
+ var SQLITE_VAR = "FILE_NEXT_SQLITE_PATH";
82
+ async function runDoctor(options = {}) {
83
+ const env = options.env ?? process.env;
84
+ const checks = [];
85
+ const provider = env.FILE_NEXT_PROVIDER;
86
+ if (!provider) {
87
+ checks.push({
88
+ name: "provider declared",
89
+ ok: false,
90
+ detail: "FILE_NEXT_PROVIDER is not set (expected 's3' or 'r2')"
91
+ });
92
+ } else if (provider !== "s3" && provider !== "r2") {
93
+ checks.push({
94
+ name: "provider valid",
95
+ ok: false,
96
+ detail: `FILE_NEXT_PROVIDER="${provider}" is not recognized (expected 's3' or 'r2')`
97
+ });
98
+ } else {
99
+ checks.push({
100
+ name: "provider valid",
101
+ ok: true,
102
+ detail: `FILE_NEXT_PROVIDER="${provider}"`
103
+ });
104
+ }
105
+ for (const v of REQUIRED_VARS) {
106
+ checks.push({
107
+ name: `env ${v}`,
108
+ ok: typeof env[v] === "string" && env[v].length > 0,
109
+ detail: env[v] ? `${v}="${env[v]}"` : `${v} is missing`
110
+ });
111
+ }
112
+ const adapter = provider === "r2" ? "postgres" : env.FILE_NEXT_ADAPTER ?? "memory";
113
+ if (adapter === "postgres") {
114
+ for (const v of POSTGRES_VARS) {
115
+ checks.push({
116
+ name: `env ${v}`,
117
+ ok: typeof env[v] === "string" && env[v].length > 0,
118
+ detail: env[v] ? `${v}="${env[v]}"` : `${v} is missing (required for postgres adapter)`
119
+ });
120
+ }
121
+ } else if (adapter === "sqlite") {
122
+ checks.push({
123
+ name: `env ${SQLITE_VAR}`,
124
+ ok: typeof env[SQLITE_VAR] === "string" && env[SQLITE_VAR].length > 0,
125
+ detail: env[SQLITE_VAR] ?? `${SQLITE_VAR} is missing (required for sqlite adapter)`
126
+ });
127
+ }
128
+ if (options.probeDb && (adapter === "postgres" || adapter === "sqlite")) {
129
+ const result = await options.probeDb(adapter);
130
+ checks.push({
131
+ name: `${adapter} reachable`,
132
+ ok: result.ok,
133
+ detail: result.detail
134
+ });
135
+ }
136
+ if (options.probeBucket && env.FILE_NEXT_BUCKET && env.FILE_NEXT_REGION) {
137
+ const result = await options.probeBucket(env.FILE_NEXT_BUCKET, env.FILE_NEXT_REGION);
138
+ checks.push({
139
+ name: "bucket reachable",
140
+ ok: result.ok,
141
+ detail: result.detail
142
+ });
143
+ }
144
+ const allOk = checks.every((c) => c.ok);
145
+ return { checks, allOk };
146
+ }
147
+ function formatDoctorReport(checks) {
148
+ const lines = checks.map((c) => {
149
+ const mark = c.ok ? "\u2713" : "\u2717";
150
+ return ` ${mark} ${c.name.padEnd(28)} ${c.detail}`;
151
+ });
152
+ const passed = checks.filter((c) => c.ok).length;
153
+ const total = checks.length;
154
+ return [`file-next doctor: ${passed}/${total} checks passed`, ...lines].join("\n") + "\n";
155
+ }
156
+
157
+ // src/index.ts
158
+ var VERSION = "0.4.0";
159
+ function printHelp(version = VERSION) {
160
+ return `file-next CLI v${version}
161
+
162
+ Usage:
163
+ file-next <command> [options]
164
+
165
+ Commands:
166
+ migrate Apply pending metadata schema migrations
167
+ --adapter=<postgres|sqlite> (required)
168
+ reconcile Detect and (optionally) fix S3 vs metadata drift
169
+ --tenant=<id> (required)
170
+ --dry-run (default: false)
171
+ doctor Diagnose the local environment
172
+ (checks env vars + adapter reachability)
173
+
174
+ Flags:
175
+ --version Print the CLI version
176
+ --help Print this help
177
+
178
+ Exit codes:
179
+ 0 success
180
+ 1 expected failure (drift detected, doctor found an issue)
181
+ 2 configuration / runtime error
182
+ `;
183
+ }
184
+ async function dispatch(argv, hooks = {}, version = VERSION) {
185
+ const out = { stdout: "", stderr: "" };
186
+ const writeOut = (s) => out.stdout += s;
187
+ const writeErr = (s) => out.stderr += s;
188
+ const command = argv[0];
189
+ switch (command) {
190
+ case "--version":
191
+ case "-v":
192
+ writeOut(`file-next ${version}
193
+ `);
194
+ return { exitCode: 0, ...out };
195
+ case "--help":
196
+ case "-h":
197
+ writeOut(printHelp(version));
198
+ return { exitCode: 0, ...out };
199
+ case void 0:
200
+ writeOut(printHelp(version));
201
+ return { exitCode: 1, ...out };
202
+ case "migrate": {
203
+ const parsed = parseMigrateArgs(argv.slice(1));
204
+ if ("error" in parsed) {
205
+ writeErr(`file-next migrate: ${parsed.error}
206
+ `);
207
+ return { exitCode: 2, ...out };
208
+ }
209
+ try {
210
+ const result = await runMigrate(parsed, hooks.migrate);
211
+ writeOut(
212
+ `Applied ${result.applied.length} migration(s); ${result.pending.length} pending.
213
+ `
214
+ );
215
+ return { exitCode: 0, ...out };
216
+ } catch (err) {
217
+ writeErr(`file-next migrate: ${err.message}
218
+ `);
219
+ return { exitCode: 2, ...out };
220
+ }
221
+ }
222
+ case "reconcile": {
223
+ const parsed = parseReconcileArgs(argv.slice(1));
224
+ if ("error" in parsed) {
225
+ writeErr(`file-next reconcile: ${parsed.error}
226
+ `);
227
+ return { exitCode: 2, ...out };
228
+ }
229
+ try {
230
+ const result = await runReconcile(parsed, hooks.reconcile);
231
+ writeOut(
232
+ `tenant=${parsed.tenant} drift: missingInS3=${result.drift.missingInS3.length} orphansInS3=${result.drift.orphansInS3.length} fixed=${result.drift.fixedCount} ${parsed.dryRun ? "(dry-run)" : ""}
233
+ `
234
+ );
235
+ return { exitCode: result.hadDrift ? 1 : 0, ...out };
236
+ } catch (err) {
237
+ writeErr(`file-next reconcile: ${err.message}
238
+ `);
239
+ return { exitCode: 2, ...out };
240
+ }
241
+ }
242
+ case "doctor": {
243
+ try {
244
+ const { checks, allOk } = await runDoctor();
245
+ writeOut(formatDoctorReport(checks));
246
+ return { exitCode: allOk ? 0 : 1, ...out };
247
+ } catch (err) {
248
+ writeErr(`file-next doctor: ${err.message}
249
+ `);
250
+ return { exitCode: 2, ...out };
251
+ }
252
+ }
253
+ default:
254
+ writeErr(`Unknown command: ${command}
255
+ Run 'file-next --help'.
256
+ `);
257
+ return { exitCode: 2, ...out };
258
+ }
259
+ }
260
+ var sqlitePath = () => process.env.FILE_NEXT_SQLITE_PATH ?? ".data/metadata.db";
261
+ var postgresUrl = () => {
262
+ const url = process.env.FILE_NEXT_DATABASE_URL ?? process.env.DATABASE_URL;
263
+ if (!url) {
264
+ throw new Error("FILE_NEXT_DATABASE_URL (or DATABASE_URL) is required");
265
+ }
266
+ return url;
267
+ };
268
+ var openStore = (adapter) => adapter === "postgres" ? createPostgresStore({ connectionString: postgresUrl() }) : createSqliteStore({ path: sqlitePath() });
269
+ var openFileSystem = (store) => {
270
+ const parsed = parseFileSystemConfig({
271
+ provider: process.env.FILE_NEXT_PROVIDER,
272
+ bucket: process.env.FILE_NEXT_BUCKET,
273
+ region: process.env.FILE_NEXT_REGION,
274
+ endpoint: process.env.FILE_NEXT_ENDPOINT,
275
+ credentials: process.env.FILE_NEXT_ACCESS_KEY_ID && process.env.FILE_NEXT_SECRET_ACCESS_KEY ? {
276
+ accessKeyId: process.env.FILE_NEXT_ACCESS_KEY_ID,
277
+ secretAccessKey: process.env.FILE_NEXT_SECRET_ACCESS_KEY
278
+ } : void 0,
279
+ forcePathStyle: process.env.FILE_NEXT_PROVIDER === "r2" ? true : process.env.FILE_NEXT_FORCE_PATH_STYLE === "true"
280
+ });
281
+ if (!parsed.ok) {
282
+ return createMemoryFileSystem({ store });
283
+ }
284
+ return createFileSystem(parsed.value, { store });
285
+ };
286
+ var defaultMigrateHooks = () => ({
287
+ resolveMigrator: async (adapter) => ({
288
+ listPending: async () => [],
289
+ apply: async () => {
290
+ const store = openStore(adapter);
291
+ await store.listChildren({
292
+ tenantId: asTenantId("cli"),
293
+ parentId: null,
294
+ limit: 1
295
+ });
296
+ return [adapter === "postgres" ? "postgres-nodes" : "sqlite-nodes"];
297
+ }
298
+ })
299
+ });
300
+ var defaultReconcileHooks = () => ({
301
+ runSync: async ({ tenant, dryRun }) => {
302
+ const adapter = process.env.FILE_NEXT_DATABASE_URL || process.env.DATABASE_URL ? "postgres" : "sqlite";
303
+ const store = openStore(adapter);
304
+ const fs = openFileSystem(store);
305
+ const wt = createWriteThrough(fs, store);
306
+ const result = await wt.reconcile({ tenantId: tenant, dryRun });
307
+ if (!result.ok) throw result.error;
308
+ return {
309
+ missingInS3: [...result.value.missingInS3],
310
+ orphansInS3: [...result.value.orphansInS3],
311
+ fixedCount: result.value.fixed
312
+ };
313
+ }
314
+ });
315
+
316
+ // src/bin.ts
317
+ dispatch(process.argv.slice(2), {
318
+ migrate: defaultMigrateHooks(),
319
+ reconcile: defaultReconcileHooks()
320
+ }).then(({ exitCode, stdout, stderr }) => {
321
+ if (stdout) process.stdout.write(stdout);
322
+ if (stderr) process.stderr.write(stderr);
323
+ process.exit(exitCode);
324
+ }).catch((err) => {
325
+ process.stderr.write(`Unexpected error: ${err.message}
326
+ `);
327
+ process.exit(2);
328
+ });
329
+ //# sourceMappingURL=bin.js.map
330
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/migrate.ts","../src/reconcile.ts","../src/doctor.ts","../src/index.ts","../src/runtime.ts","../src/bin.ts"],"names":["parseArgs"],"mappings":"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","file":"bin.js","sourcesContent":["/**\n * `file-next migrate` — apply pending metadata schema migrations.\n *\n * v0.1 scope: prints the migration plan and reports what would run.\n * Actual migration execution is delegated to the adapter package's\n * migrator (Drizzle for postgres/sqlite). This CLI is the orchestrator\n * that resolves the adapter from the config and invokes it.\n *\n * Usage:\n * file-next migrate --adapter=postgres\n * file-next migrate --adapter=sqlite\n *\n * Exit codes:\n * 0 — migrations applied (or none pending)\n * 1 — configuration error (missing / unknown adapter)\n * 2 — migration failed (the adapter threw)\n */\nimport { parseArgs } from \"node:util\";\n\nexport interface MigrateOptions {\n readonly adapter: \"postgres\" | \"sqlite\";\n}\n\nexport function parseMigrateArgs(argv: ReadonlyArray<string>): MigrateOptions | { error: string } {\n let parsed: ReturnType<typeof parseArgs>;\n try {\n parsed = parseArgs({\n args: argv as string[],\n options: {\n adapter: { type: \"string\", short: \"a\" },\n },\n strict: true,\n allowPositionals: false,\n });\n } catch (err) {\n return { error: (err as Error).message };\n }\n\n const adapter = parsed.values.adapter;\n if (adapter !== \"postgres\" && adapter !== \"sqlite\") {\n return { error: \"--adapter must be either 'postgres' or 'sqlite'\" };\n }\n return { adapter };\n}\n\nexport interface MigrateResult {\n readonly applied: ReadonlyArray<string>;\n readonly pending: ReadonlyArray<string>;\n}\n\n/**\n * Resolve and run migrations against the chosen adapter.\n *\n * v0.1 implementation: returns a static plan rather than touching\n * a real database. The hook for `runAdapterMigrations` is the\n * integration point for v0.2 (which will load Drizzle's migrator\n * dynamically based on the adapter choice).\n */\nexport interface MigrateHooks {\n /** Resolves the migrator implementation. May throw on unsupported adapter. */\n resolveMigrator: (adapter: MigrateOptions[\"adapter\"]) => Promise<{\n listPending: () => Promise<ReadonlyArray<string>>;\n apply: () => Promise<ReadonlyArray<string>>;\n }>;\n}\n\nexport async function runMigrate(\n options: MigrateOptions,\n hooks: MigrateHooks = {\n // Default no-op migrator so the CLI can be smoke-tested without\n // a real DB. The real adapter wiring lives in the consumer's\n // app where drizzle-orm is a runtime dep.\n resolveMigrator: async () => ({\n listPending: async () => [],\n apply: async () => [],\n }),\n },\n): Promise<MigrateResult> {\n const migrator = await hooks.resolveMigrator(options.adapter);\n const pending = await migrator.listPending();\n const applied = await migrator.apply();\n return { applied, pending };\n}\n","/**\n * `file-next reconcile` — find and (optionally) fix drift between\n * the S3 bucket and the metadata index.\n *\n * Usage:\n * file-next reconcile --tenant=acme\n * file-next reconcile --tenant=acme --dry-run\n *\n * Exit codes:\n * 0 — no drift detected (or all drift fixed)\n * 1 — drift detected AND not fixed (dry-run mode)\n * 2 — configuration / connectivity error\n */\nimport { parseArgs } from \"node:util\";\n\nexport interface ReconcileOptions {\n readonly tenant: string;\n readonly dryRun: boolean;\n}\n\nexport function parseReconcileArgs(\n argv: ReadonlyArray<string>,\n): ReconcileOptions | { error: string } {\n let parsed: ReturnType<typeof parseArgs>;\n try {\n parsed = parseArgs({\n args: argv as string[],\n options: {\n tenant: { type: \"string\", short: \"t\" },\n \"dry-run\": { type: \"boolean\", default: false },\n },\n strict: true,\n allowPositionals: false,\n });\n } catch (err) {\n return { error: (err as Error).message };\n }\n\n if (typeof parsed.values.tenant !== \"string\" || parsed.values.tenant.length === 0) {\n return { error: \"--tenant is required\" };\n }\n return {\n tenant: parsed.values.tenant,\n dryRun: parsed.values[\"dry-run\"] === true,\n };\n}\n\nexport interface DriftReport {\n readonly missingInS3: ReadonlyArray<string>;\n readonly orphansInS3: ReadonlyArray<string>;\n readonly fixedCount: number;\n}\n\nexport interface ReconcileResult {\n readonly drift: DriftReport;\n readonly hadDrift: boolean;\n}\n\n/**\n * Run a reconcile cycle for a tenant.\n *\n * v0.1: the actual sync logic is provided via the `runSync` hook so\n * this CLI is testable without a real Postgres / S3 backend. The\n * consumer wires the real `reconcile()` from `@vryzel/file-next/sync` into\n * the hook when running the CLI against production.\n */\nexport interface ReconcileHooks {\n runSync: (input: {\n tenant: string;\n dryRun: boolean;\n }) => Promise<DriftReport>;\n}\n\nexport async function runReconcile(\n options: ReconcileOptions,\n hooks: ReconcileHooks = {\n runSync: async () => ({\n missingInS3: [],\n orphansInS3: [],\n fixedCount: 0,\n }),\n },\n): Promise<ReconcileResult> {\n const drift = await hooks.runSync({\n tenant: options.tenant,\n dryRun: options.dryRun,\n });\n const hadDrift = drift.missingInS3.length + drift.orphansInS3.length > 0;\n return { drift, hadDrift };\n}\n","/**\n * `file-next doctor` — diagnose the local environment for file-next.\n *\n * Checks (each pass/fail):\n * - Required env vars present (FILE_NEXT_BUCKET, FILE_NEXT_REGION,\n * FILE_NEXT_PROVIDER, and adapter-specific creds).\n * - Metadata DB reachable (postgres / sqlite path resolves).\n * - S3 bucket reachable (HEAD on the configured bucket returns 200\n * or 403 — not network error).\n *\n * Usage:\n * file-next doctor\n *\n * Exit codes:\n * 0 — all checks pass\n * 1 — one or more checks failed (printed to stderr)\n * 2 — invalid configuration (e.g. unknown provider)\n */\n\nexport interface DoctorCheck {\n readonly name: string;\n readonly ok: boolean;\n readonly detail: string;\n}\n\nexport interface DoctorOptions {\n /** Env vars to validate. The CLI uses process.env by default. */\n readonly env?: NodeJS.ProcessEnv;\n /** Optional callback to test DB reachability. */\n readonly probeDb?: (provider: \"postgres\" | \"sqlite\") => Promise<{ ok: boolean; detail: string }>;\n /** Optional callback to test S3 bucket reachability. */\n readonly probeBucket?: (bucket: string, region: string) => Promise<{ ok: boolean; detail: string }>;\n}\n\nconst REQUIRED_VARS = [\"FILE_NEXT_BUCKET\", \"FILE_NEXT_REGION\", \"FILE_NEXT_PROVIDER\"] as const;\nconst POSTGRES_VARS = [\"FILE_NEXT_PG_HOST\", \"FILE_NEXT_PG_USER\", \"FILE_NEXT_PG_DATABASE\"] as const;\nconst SQLITE_VAR = \"FILE_NEXT_SQLITE_PATH\";\n\n/**\n * Run all checks. Returns a list of { name, ok, detail } records.\n * The CLI formats and prints them; the caller decides the exit code.\n */\nexport async function runDoctor(options: DoctorOptions = {}): Promise<{\n checks: ReadonlyArray<DoctorCheck>;\n allOk: boolean;\n}> {\n const env = options.env ?? process.env;\n const checks: DoctorCheck[] = [];\n\n // 1. Provider declared\n const provider = env.FILE_NEXT_PROVIDER;\n if (!provider) {\n checks.push({\n name: \"provider declared\",\n ok: false,\n detail: \"FILE_NEXT_PROVIDER is not set (expected 's3' or 'r2')\",\n });\n } else if (provider !== \"s3\" && provider !== \"r2\") {\n checks.push({\n name: \"provider valid\",\n ok: false,\n detail: `FILE_NEXT_PROVIDER=\"${provider}\" is not recognized (expected 's3' or 'r2')`,\n });\n } else {\n checks.push({\n name: \"provider valid\",\n ok: true,\n detail: `FILE_NEXT_PROVIDER=\"${provider}\"`,\n });\n }\n\n // 2. Required env vars present\n for (const v of REQUIRED_VARS) {\n checks.push({\n name: `env ${v}`,\n ok: typeof env[v] === \"string\" && env[v]!.length > 0,\n detail: env[v] ? `${v}=\"${env[v]}\"` : `${v} is missing`,\n });\n }\n\n // 3. Adapter-specific vars\n const adapter = provider === \"r2\" ? \"postgres\" : (env.FILE_NEXT_ADAPTER ?? \"memory\");\n if (adapter === \"postgres\") {\n for (const v of POSTGRES_VARS) {\n checks.push({\n name: `env ${v}`,\n ok: typeof env[v] === \"string\" && env[v]!.length > 0,\n detail: env[v] ? `${v}=\"${env[v]}\"` : `${v} is missing (required for postgres adapter)`,\n });\n }\n } else if (adapter === \"sqlite\") {\n checks.push({\n name: `env ${SQLITE_VAR}`,\n ok: typeof env[SQLITE_VAR] === \"string\" && env[SQLITE_VAR]!.length > 0,\n detail: env[SQLITE_VAR] ?? `${SQLITE_VAR} is missing (required for sqlite adapter)`,\n });\n }\n\n // 4. DB reachability (only if probe provided)\n if (options.probeDb && (adapter === \"postgres\" || adapter === \"sqlite\")) {\n const result = await options.probeDb(adapter);\n checks.push({\n name: `${adapter} reachable`,\n ok: result.ok,\n detail: result.detail,\n });\n }\n\n // 5. S3 reachability (only if probe provided)\n if (options.probeBucket && env.FILE_NEXT_BUCKET && env.FILE_NEXT_REGION) {\n const result = await options.probeBucket(env.FILE_NEXT_BUCKET, env.FILE_NEXT_REGION);\n checks.push({\n name: \"bucket reachable\",\n ok: result.ok,\n detail: result.detail,\n });\n }\n\n const allOk = checks.every((c) => c.ok);\n return { checks, allOk };\n}\n\n/**\n * Pretty-print the doctor report to stdout. Used by the CLI dispatcher.\n */\nexport function formatDoctorReport(checks: ReadonlyArray<DoctorCheck>): string {\n const lines = checks.map((c) => {\n const mark = c.ok ? \"✓\" : \"✗\";\n return ` ${mark} ${c.name.padEnd(28)} ${c.detail}`;\n });\n const passed = checks.filter((c) => c.ok).length;\n const total = checks.length;\n return [`file-next doctor: ${passed}/${total} checks passed`, ...lines].join(\"\\n\") + \"\\n\";\n}\n","/**\n * `file-next` CLI library — exports `dispatch()` and command parsers\n * for programmatic use AND for the bin shim.\n *\n * The actual binary entry point is `./bin.mjs` (in this directory;\n * tsup writes it to `dist/bin.mjs`). It imports `dispatch` from\n * here and handles the process.exit / stdout / stderr wiring.\n *\n * Why split it this way:\n * - Tests can import from `@/index` (this file) without triggering\n * any process.exit side effects.\n * - The bin file is tiny (3 lines) and exists solely to translate\n * `dispatch()`'s return value into the correct process exit +\n * stdout/stderr writes.\n * - No brittle `import.meta.url === process.argv[1]` heuristics\n * (those break on macOS symlinks like /tmp → /private/tmp).\n */\nimport { parseMigrateArgs, runMigrate, type MigrateHooks } from \"./migrate.js\";\nimport { parseReconcileArgs, runReconcile, type ReconcileHooks } from \"./reconcile.js\";\nimport { runDoctor, formatDoctorReport } from \"./doctor.js\";\n\nexport const VERSION = \"0.4.0\";\n\nexport function printHelp(version: string = VERSION): string {\n return `file-next CLI v${version}\n\nUsage:\n file-next <command> [options]\n\nCommands:\n migrate Apply pending metadata schema migrations\n --adapter=<postgres|sqlite> (required)\n reconcile Detect and (optionally) fix S3 vs metadata drift\n --tenant=<id> (required)\n --dry-run (default: false)\n doctor Diagnose the local environment\n (checks env vars + adapter reachability)\n\nFlags:\n --version Print the CLI version\n --help Print this help\n\nExit codes:\n 0 success\n 1 expected failure (drift detected, doctor found an issue)\n 2 configuration / runtime error\n`;\n}\n\nexport interface DispatchHooks {\n readonly migrate?: MigrateHooks;\n readonly reconcile?: ReconcileHooks;\n}\n\n/**\n * Dispatch a CLI invocation to the right command and return the exit code.\n *\n * Does NOT call `process.exit` — that is the caller's job (the bin\n * script at the bottom of this file). Pure dispatch logic so it\n * can be unit-tested without spawning a subprocess.\n */\nexport async function dispatch(\n argv: ReadonlyArray<string>,\n hooks: DispatchHooks = {},\n version: string = VERSION,\n): Promise<{ exitCode: number; stdout: string; stderr: string }> {\n const out = { stdout: \"\", stderr: \"\" };\n const writeOut = (s: string) => (out.stdout += s);\n const writeErr = (s: string) => (out.stderr += s);\n const command = argv[0];\n\n switch (command) {\n case \"--version\":\n case \"-v\":\n writeOut(`file-next ${version}\\n`);\n return { exitCode: 0, ...out };\n\n case \"--help\":\n case \"-h\":\n writeOut(printHelp(version));\n return { exitCode: 0, ...out };\n\n case undefined:\n writeOut(printHelp(version));\n return { exitCode: 1, ...out };\n\n case \"migrate\": {\n const parsed = parseMigrateArgs(argv.slice(1));\n if (\"error\" in parsed) {\n writeErr(`file-next migrate: ${parsed.error}\\n`);\n return { exitCode: 2, ...out };\n }\n try {\n const result = await runMigrate(parsed, hooks.migrate);\n writeOut(\n `Applied ${result.applied.length} migration(s); ${result.pending.length} pending.\\n`,\n );\n return { exitCode: 0, ...out };\n } catch (err) {\n writeErr(`file-next migrate: ${(err as Error).message}\\n`);\n return { exitCode: 2, ...out };\n }\n }\n\n case \"reconcile\": {\n const parsed = parseReconcileArgs(argv.slice(1));\n if (\"error\" in parsed) {\n writeErr(`file-next reconcile: ${parsed.error}\\n`);\n return { exitCode: 2, ...out };\n }\n try {\n const result = await runReconcile(parsed, hooks.reconcile);\n writeOut(\n `tenant=${parsed.tenant} drift: missingInS3=${result.drift.missingInS3.length} ` +\n `orphansInS3=${result.drift.orphansInS3.length} ` +\n `fixed=${result.drift.fixedCount} ` +\n `${parsed.dryRun ? \"(dry-run)\" : \"\"}\\n`,\n );\n return { exitCode: result.hadDrift ? 1 : 0, ...out };\n } catch (err) {\n writeErr(`file-next reconcile: ${(err as Error).message}\\n`);\n return { exitCode: 2, ...out };\n }\n }\n\n case \"doctor\": {\n try {\n const { checks, allOk } = await runDoctor();\n writeOut(formatDoctorReport(checks));\n return { exitCode: allOk ? 0 : 1, ...out };\n } catch (err) {\n writeErr(`file-next doctor: ${(err as Error).message}\\n`);\n return { exitCode: 2, ...out };\n }\n }\n\n default:\n writeErr(`Unknown command: ${command}\\nRun 'file-next --help'.\\n`);\n return { exitCode: 2, ...out };\n }\n}\n\n// No top-level process.exit / dispatch here. The bin shim\n// (`bin.mjs` in this directory; emitted to `dist/bin.mjs` by tsup)\n// is the only entry point that calls `dispatch()` and writes\n// its stdout/stderr to the actual process streams.\n","/**\n * Default CLI hooks — actually open the store / filesystem from env.\n */\nimport {\n asTenantId,\n createFileSystem,\n createMemoryFileSystem,\n createPostgresStore,\n createSqliteStore,\n parseFileSystemConfig,\n type MetadataStore,\n} from \"@vryzel/file-next\";\nimport { createWriteThrough } from \"@vryzel/file-next/sync\";\nimport type { MigrateHooks } from \"./migrate.js\";\nimport type { ReconcileHooks } from \"./reconcile.js\";\n\nconst sqlitePath = (): string =>\n process.env.FILE_NEXT_SQLITE_PATH ?? \".data/metadata.db\";\n\nconst postgresUrl = (): string => {\n const url = process.env.FILE_NEXT_DATABASE_URL ?? process.env.DATABASE_URL;\n if (!url) {\n throw new Error(\"FILE_NEXT_DATABASE_URL (or DATABASE_URL) is required\");\n }\n return url;\n};\n\nconst openStore = (adapter: \"postgres\" | \"sqlite\"): MetadataStore =>\n adapter === \"postgres\"\n ? createPostgresStore({ connectionString: postgresUrl() })\n : createSqliteStore({ path: sqlitePath() });\n\nconst openFileSystem = (store: MetadataStore) => {\n const parsed = parseFileSystemConfig({\n provider: process.env.FILE_NEXT_PROVIDER,\n bucket: process.env.FILE_NEXT_BUCKET,\n region: process.env.FILE_NEXT_REGION,\n endpoint: process.env.FILE_NEXT_ENDPOINT,\n credentials:\n process.env.FILE_NEXT_ACCESS_KEY_ID && process.env.FILE_NEXT_SECRET_ACCESS_KEY\n ? {\n accessKeyId: process.env.FILE_NEXT_ACCESS_KEY_ID,\n secretAccessKey: process.env.FILE_NEXT_SECRET_ACCESS_KEY,\n }\n : undefined,\n forcePathStyle:\n process.env.FILE_NEXT_PROVIDER === \"r2\"\n ? true\n : process.env.FILE_NEXT_FORCE_PATH_STYLE === \"true\",\n });\n if (!parsed.ok) {\n return createMemoryFileSystem({ store });\n }\n return createFileSystem(parsed.value, { store });\n};\n\nexport const defaultMigrateHooks = (): MigrateHooks => ({\n resolveMigrator: async (adapter) => ({\n listPending: async () => [],\n apply: async () => {\n const store = openStore(adapter);\n await store.listChildren({\n tenantId: asTenantId(\"cli\"),\n parentId: null,\n limit: 1,\n });\n return [adapter === \"postgres\" ? \"postgres-nodes\" : \"sqlite-nodes\"];\n },\n }),\n});\n\nexport const defaultReconcileHooks = (): ReconcileHooks => ({\n runSync: async ({ tenant, dryRun }) => {\n const adapter =\n process.env.FILE_NEXT_DATABASE_URL || process.env.DATABASE_URL\n ? \"postgres\"\n : \"sqlite\";\n const store = openStore(adapter);\n const fs = openFileSystem(store);\n const wt = createWriteThrough(fs, store);\n const result = await wt.reconcile({ tenantId: tenant, dryRun });\n if (!result.ok) throw result.error;\n return {\n missingInS3: [...result.value.missingInS3],\n orphansInS3: [...result.value.orphansInS3],\n fixedCount: result.value.fixed,\n };\n },\n});\n","#!/usr/bin/env node\n/**\n * Bin shim for `@vryzel/file-next-cli`.\n *\n * This file is the only place that translates the pure `dispatch()`\n * return value into process streams + exit. Splitting it out keeps\n * `src/index.ts` side-effect-free (importable by tests without\n * triggering any process.exit).\n *\n * tsup writes this file to `dist/bin.mjs`; the `bin` field in\n * package.json points the `file-next` command at it.\n */\nimport { dispatch } from \"./index.js\";\nimport { defaultMigrateHooks, defaultReconcileHooks } from \"./runtime.js\";\n\ndispatch(process.argv.slice(2), {\n migrate: defaultMigrateHooks(),\n reconcile: defaultReconcileHooks(),\n})\n .then(({ exitCode, stdout, stderr }) => {\n if (stdout) process.stdout.write(stdout);\n if (stderr) process.stderr.write(stderr);\n process.exit(exitCode);\n })\n .catch((err: unknown) => {\n process.stderr.write(`Unexpected error: ${(err as Error).message}\\n`);\n process.exit(2);\n });\n"]}
@@ -0,0 +1,77 @@
1
+ interface MigrateOptions {
2
+ readonly adapter: "postgres" | "sqlite";
3
+ }
4
+ /**
5
+ * Resolve and run migrations against the chosen adapter.
6
+ *
7
+ * v0.1 implementation: returns a static plan rather than touching
8
+ * a real database. The hook for `runAdapterMigrations` is the
9
+ * integration point for v0.2 (which will load Drizzle's migrator
10
+ * dynamically based on the adapter choice).
11
+ */
12
+ interface MigrateHooks {
13
+ /** Resolves the migrator implementation. May throw on unsupported adapter. */
14
+ resolveMigrator: (adapter: MigrateOptions["adapter"]) => Promise<{
15
+ listPending: () => Promise<ReadonlyArray<string>>;
16
+ apply: () => Promise<ReadonlyArray<string>>;
17
+ }>;
18
+ }
19
+
20
+ interface DriftReport {
21
+ readonly missingInS3: ReadonlyArray<string>;
22
+ readonly orphansInS3: ReadonlyArray<string>;
23
+ readonly fixedCount: number;
24
+ }
25
+ /**
26
+ * Run a reconcile cycle for a tenant.
27
+ *
28
+ * v0.1: the actual sync logic is provided via the `runSync` hook so
29
+ * this CLI is testable without a real Postgres / S3 backend. The
30
+ * consumer wires the real `reconcile()` from `@vryzel/file-next/sync` into
31
+ * the hook when running the CLI against production.
32
+ */
33
+ interface ReconcileHooks {
34
+ runSync: (input: {
35
+ tenant: string;
36
+ dryRun: boolean;
37
+ }) => Promise<DriftReport>;
38
+ }
39
+
40
+ /**
41
+ * `file-next` CLI library — exports `dispatch()` and command parsers
42
+ * for programmatic use AND for the bin shim.
43
+ *
44
+ * The actual binary entry point is `./bin.mjs` (in this directory;
45
+ * tsup writes it to `dist/bin.mjs`). It imports `dispatch` from
46
+ * here and handles the process.exit / stdout / stderr wiring.
47
+ *
48
+ * Why split it this way:
49
+ * - Tests can import from `@/index` (this file) without triggering
50
+ * any process.exit side effects.
51
+ * - The bin file is tiny (3 lines) and exists solely to translate
52
+ * `dispatch()`'s return value into the correct process exit +
53
+ * stdout/stderr writes.
54
+ * - No brittle `import.meta.url === process.argv[1]` heuristics
55
+ * (those break on macOS symlinks like /tmp → /private/tmp).
56
+ */
57
+
58
+ declare const VERSION = "0.4.0";
59
+ declare function printHelp(version?: string): string;
60
+ interface DispatchHooks {
61
+ readonly migrate?: MigrateHooks;
62
+ readonly reconcile?: ReconcileHooks;
63
+ }
64
+ /**
65
+ * Dispatch a CLI invocation to the right command and return the exit code.
66
+ *
67
+ * Does NOT call `process.exit` — that is the caller's job (the bin
68
+ * script at the bottom of this file). Pure dispatch logic so it
69
+ * can be unit-tested without spawning a subprocess.
70
+ */
71
+ declare function dispatch(argv: ReadonlyArray<string>, hooks?: DispatchHooks, version?: string): Promise<{
72
+ exitCode: number;
73
+ stdout: string;
74
+ stderr: string;
75
+ }>;
76
+
77
+ export { type DispatchHooks, VERSION, dispatch, printHelp };
package/dist/index.js ADDED
@@ -0,0 +1,261 @@
1
+ import { parseArgs } from 'util';
2
+
3
+ // src/migrate.ts
4
+ function parseMigrateArgs(argv) {
5
+ let parsed;
6
+ try {
7
+ parsed = parseArgs({
8
+ args: argv,
9
+ options: {
10
+ adapter: { type: "string", short: "a" }
11
+ },
12
+ strict: true,
13
+ allowPositionals: false
14
+ });
15
+ } catch (err) {
16
+ return { error: err.message };
17
+ }
18
+ const adapter = parsed.values.adapter;
19
+ if (adapter !== "postgres" && adapter !== "sqlite") {
20
+ return { error: "--adapter must be either 'postgres' or 'sqlite'" };
21
+ }
22
+ return { adapter };
23
+ }
24
+ async function runMigrate(options, hooks = {
25
+ // Default no-op migrator so the CLI can be smoke-tested without
26
+ // a real DB. The real adapter wiring lives in the consumer's
27
+ // app where drizzle-orm is a runtime dep.
28
+ resolveMigrator: async () => ({
29
+ listPending: async () => [],
30
+ apply: async () => []
31
+ })
32
+ }) {
33
+ const migrator = await hooks.resolveMigrator(options.adapter);
34
+ const pending = await migrator.listPending();
35
+ const applied = await migrator.apply();
36
+ return { applied, pending };
37
+ }
38
+ function parseReconcileArgs(argv) {
39
+ let parsed;
40
+ try {
41
+ parsed = parseArgs({
42
+ args: argv,
43
+ options: {
44
+ tenant: { type: "string", short: "t" },
45
+ "dry-run": { type: "boolean", default: false }
46
+ },
47
+ strict: true,
48
+ allowPositionals: false
49
+ });
50
+ } catch (err) {
51
+ return { error: err.message };
52
+ }
53
+ if (typeof parsed.values.tenant !== "string" || parsed.values.tenant.length === 0) {
54
+ return { error: "--tenant is required" };
55
+ }
56
+ return {
57
+ tenant: parsed.values.tenant,
58
+ dryRun: parsed.values["dry-run"] === true
59
+ };
60
+ }
61
+ async function runReconcile(options, hooks = {
62
+ runSync: async () => ({
63
+ missingInS3: [],
64
+ orphansInS3: [],
65
+ fixedCount: 0
66
+ })
67
+ }) {
68
+ const drift = await hooks.runSync({
69
+ tenant: options.tenant,
70
+ dryRun: options.dryRun
71
+ });
72
+ const hadDrift = drift.missingInS3.length + drift.orphansInS3.length > 0;
73
+ return { drift, hadDrift };
74
+ }
75
+
76
+ // src/doctor.ts
77
+ var REQUIRED_VARS = ["FILE_NEXT_BUCKET", "FILE_NEXT_REGION", "FILE_NEXT_PROVIDER"];
78
+ var POSTGRES_VARS = ["FILE_NEXT_PG_HOST", "FILE_NEXT_PG_USER", "FILE_NEXT_PG_DATABASE"];
79
+ var SQLITE_VAR = "FILE_NEXT_SQLITE_PATH";
80
+ async function runDoctor(options = {}) {
81
+ const env = options.env ?? process.env;
82
+ const checks = [];
83
+ const provider = env.FILE_NEXT_PROVIDER;
84
+ if (!provider) {
85
+ checks.push({
86
+ name: "provider declared",
87
+ ok: false,
88
+ detail: "FILE_NEXT_PROVIDER is not set (expected 's3' or 'r2')"
89
+ });
90
+ } else if (provider !== "s3" && provider !== "r2") {
91
+ checks.push({
92
+ name: "provider valid",
93
+ ok: false,
94
+ detail: `FILE_NEXT_PROVIDER="${provider}" is not recognized (expected 's3' or 'r2')`
95
+ });
96
+ } else {
97
+ checks.push({
98
+ name: "provider valid",
99
+ ok: true,
100
+ detail: `FILE_NEXT_PROVIDER="${provider}"`
101
+ });
102
+ }
103
+ for (const v of REQUIRED_VARS) {
104
+ checks.push({
105
+ name: `env ${v}`,
106
+ ok: typeof env[v] === "string" && env[v].length > 0,
107
+ detail: env[v] ? `${v}="${env[v]}"` : `${v} is missing`
108
+ });
109
+ }
110
+ const adapter = provider === "r2" ? "postgres" : env.FILE_NEXT_ADAPTER ?? "memory";
111
+ if (adapter === "postgres") {
112
+ for (const v of POSTGRES_VARS) {
113
+ checks.push({
114
+ name: `env ${v}`,
115
+ ok: typeof env[v] === "string" && env[v].length > 0,
116
+ detail: env[v] ? `${v}="${env[v]}"` : `${v} is missing (required for postgres adapter)`
117
+ });
118
+ }
119
+ } else if (adapter === "sqlite") {
120
+ checks.push({
121
+ name: `env ${SQLITE_VAR}`,
122
+ ok: typeof env[SQLITE_VAR] === "string" && env[SQLITE_VAR].length > 0,
123
+ detail: env[SQLITE_VAR] ?? `${SQLITE_VAR} is missing (required for sqlite adapter)`
124
+ });
125
+ }
126
+ if (options.probeDb && (adapter === "postgres" || adapter === "sqlite")) {
127
+ const result = await options.probeDb(adapter);
128
+ checks.push({
129
+ name: `${adapter} reachable`,
130
+ ok: result.ok,
131
+ detail: result.detail
132
+ });
133
+ }
134
+ if (options.probeBucket && env.FILE_NEXT_BUCKET && env.FILE_NEXT_REGION) {
135
+ const result = await options.probeBucket(env.FILE_NEXT_BUCKET, env.FILE_NEXT_REGION);
136
+ checks.push({
137
+ name: "bucket reachable",
138
+ ok: result.ok,
139
+ detail: result.detail
140
+ });
141
+ }
142
+ const allOk = checks.every((c) => c.ok);
143
+ return { checks, allOk };
144
+ }
145
+ function formatDoctorReport(checks) {
146
+ const lines = checks.map((c) => {
147
+ const mark = c.ok ? "\u2713" : "\u2717";
148
+ return ` ${mark} ${c.name.padEnd(28)} ${c.detail}`;
149
+ });
150
+ const passed = checks.filter((c) => c.ok).length;
151
+ const total = checks.length;
152
+ return [`file-next doctor: ${passed}/${total} checks passed`, ...lines].join("\n") + "\n";
153
+ }
154
+
155
+ // src/index.ts
156
+ var VERSION = "0.4.0";
157
+ function printHelp(version = VERSION) {
158
+ return `file-next CLI v${version}
159
+
160
+ Usage:
161
+ file-next <command> [options]
162
+
163
+ Commands:
164
+ migrate Apply pending metadata schema migrations
165
+ --adapter=<postgres|sqlite> (required)
166
+ reconcile Detect and (optionally) fix S3 vs metadata drift
167
+ --tenant=<id> (required)
168
+ --dry-run (default: false)
169
+ doctor Diagnose the local environment
170
+ (checks env vars + adapter reachability)
171
+
172
+ Flags:
173
+ --version Print the CLI version
174
+ --help Print this help
175
+
176
+ Exit codes:
177
+ 0 success
178
+ 1 expected failure (drift detected, doctor found an issue)
179
+ 2 configuration / runtime error
180
+ `;
181
+ }
182
+ async function dispatch(argv, hooks = {}, version = VERSION) {
183
+ const out = { stdout: "", stderr: "" };
184
+ const writeOut = (s) => out.stdout += s;
185
+ const writeErr = (s) => out.stderr += s;
186
+ const command = argv[0];
187
+ switch (command) {
188
+ case "--version":
189
+ case "-v":
190
+ writeOut(`file-next ${version}
191
+ `);
192
+ return { exitCode: 0, ...out };
193
+ case "--help":
194
+ case "-h":
195
+ writeOut(printHelp(version));
196
+ return { exitCode: 0, ...out };
197
+ case void 0:
198
+ writeOut(printHelp(version));
199
+ return { exitCode: 1, ...out };
200
+ case "migrate": {
201
+ const parsed = parseMigrateArgs(argv.slice(1));
202
+ if ("error" in parsed) {
203
+ writeErr(`file-next migrate: ${parsed.error}
204
+ `);
205
+ return { exitCode: 2, ...out };
206
+ }
207
+ try {
208
+ const result = await runMigrate(parsed, hooks.migrate);
209
+ writeOut(
210
+ `Applied ${result.applied.length} migration(s); ${result.pending.length} pending.
211
+ `
212
+ );
213
+ return { exitCode: 0, ...out };
214
+ } catch (err) {
215
+ writeErr(`file-next migrate: ${err.message}
216
+ `);
217
+ return { exitCode: 2, ...out };
218
+ }
219
+ }
220
+ case "reconcile": {
221
+ const parsed = parseReconcileArgs(argv.slice(1));
222
+ if ("error" in parsed) {
223
+ writeErr(`file-next reconcile: ${parsed.error}
224
+ `);
225
+ return { exitCode: 2, ...out };
226
+ }
227
+ try {
228
+ const result = await runReconcile(parsed, hooks.reconcile);
229
+ writeOut(
230
+ `tenant=${parsed.tenant} drift: missingInS3=${result.drift.missingInS3.length} orphansInS3=${result.drift.orphansInS3.length} fixed=${result.drift.fixedCount} ${parsed.dryRun ? "(dry-run)" : ""}
231
+ `
232
+ );
233
+ return { exitCode: result.hadDrift ? 1 : 0, ...out };
234
+ } catch (err) {
235
+ writeErr(`file-next reconcile: ${err.message}
236
+ `);
237
+ return { exitCode: 2, ...out };
238
+ }
239
+ }
240
+ case "doctor": {
241
+ try {
242
+ const { checks, allOk } = await runDoctor();
243
+ writeOut(formatDoctorReport(checks));
244
+ return { exitCode: allOk ? 0 : 1, ...out };
245
+ } catch (err) {
246
+ writeErr(`file-next doctor: ${err.message}
247
+ `);
248
+ return { exitCode: 2, ...out };
249
+ }
250
+ }
251
+ default:
252
+ writeErr(`Unknown command: ${command}
253
+ Run 'file-next --help'.
254
+ `);
255
+ return { exitCode: 2, ...out };
256
+ }
257
+ }
258
+
259
+ export { VERSION, dispatch, printHelp };
260
+ //# sourceMappingURL=index.js.map
261
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/migrate.ts","../src/reconcile.ts","../src/doctor.ts","../src/index.ts"],"names":["parseArgs"],"mappings":";;;AAuBO,SAAS,iBAAiB,IAAA,EAAiE;AAChG,EAAA,IAAI,MAAA;AACJ,EAAA,IAAI;AACF,IAAA,MAAA,GAAS,SAAA,CAAU;AAAA,MACjB,IAAA,EAAM,IAAA;AAAA,MACN,OAAA,EAAS;AAAA,QACP,OAAA,EAAS,EAAE,IAAA,EAAM,QAAA,EAAU,OAAO,GAAA;AAAI,OACxC;AAAA,MACA,MAAA,EAAQ,IAAA;AAAA,MACR,gBAAA,EAAkB;AAAA,KACnB,CAAA;AAAA,EACH,SAAS,GAAA,EAAK;AACZ,IAAA,OAAO,EAAE,KAAA,EAAQ,GAAA,CAAc,OAAA,EAAQ;AAAA,EACzC;AAEA,EAAA,MAAM,OAAA,GAAU,OAAO,MAAA,CAAO,OAAA;AAC9B,EAAA,IAAI,OAAA,KAAY,UAAA,IAAc,OAAA,KAAY,QAAA,EAAU;AAClD,IAAA,OAAO,EAAE,OAAO,iDAAA,EAAkD;AAAA,EACpE;AACA,EAAA,OAAO,EAAE,OAAA,EAAQ;AACnB;AAuBA,eAAsB,UAAA,CACpB,SACA,KAAA,GAAsB;AAAA;AAAA;AAAA;AAAA,EAIpB,iBAAiB,aAAa;AAAA,IAC5B,WAAA,EAAa,YAAY,EAAC;AAAA,IAC1B,KAAA,EAAO,YAAY;AAAC,GACtB;AACF,CAAA,EACwB;AACxB,EAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,eAAA,CAAgB,QAAQ,OAAO,CAAA;AAC5D,EAAA,MAAM,OAAA,GAAU,MAAM,QAAA,CAAS,WAAA,EAAY;AAC3C,EAAA,MAAM,OAAA,GAAU,MAAM,QAAA,CAAS,KAAA,EAAM;AACrC,EAAA,OAAO,EAAE,SAAS,OAAA,EAAQ;AAC5B;AC9DO,SAAS,mBACd,IAAA,EACsC;AACtC,EAAA,IAAI,MAAA;AACJ,EAAA,IAAI;AACF,IAAA,MAAA,GAASA,SAAAA,CAAU;AAAA,MACjB,IAAA,EAAM,IAAA;AAAA,MACN,OAAA,EAAS;AAAA,QACP,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,OAAO,GAAA,EAAI;AAAA,QACrC,SAAA,EAAW,EAAE,IAAA,EAAM,SAAA,EAAW,SAAS,KAAA;AAAM,OAC/C;AAAA,MACA,MAAA,EAAQ,IAAA;AAAA,MACR,gBAAA,EAAkB;AAAA,KACnB,CAAA;AAAA,EACH,SAAS,GAAA,EAAK;AACZ,IAAA,OAAO,EAAE,KAAA,EAAQ,GAAA,CAAc,OAAA,EAAQ;AAAA,EACzC;AAEA,EAAA,IAAI,OAAO,OAAO,MAAA,CAAO,MAAA,KAAW,YAAY,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,MAAA,KAAW,CAAA,EAAG;AACjF,IAAA,OAAO,EAAE,OAAO,sBAAA,EAAuB;AAAA,EACzC;AACA,EAAA,OAAO;AAAA,IACL,MAAA,EAAQ,OAAO,MAAA,CAAO,MAAA;AAAA,IACtB,MAAA,EAAQ,MAAA,CAAO,MAAA,CAAO,SAAS,CAAA,KAAM;AAAA,GACvC;AACF;AA4BA,eAAsB,YAAA,CACpB,SACA,KAAA,GAAwB;AAAA,EACtB,SAAS,aAAa;AAAA,IACpB,aAAa,EAAC;AAAA,IACd,aAAa,EAAC;AAAA,IACd,UAAA,EAAY;AAAA,GACd;AACF,CAAA,EAC0B;AAC1B,EAAA,MAAM,KAAA,GAAQ,MAAM,KAAA,CAAM,OAAA,CAAQ;AAAA,IAChC,QAAQ,OAAA,CAAQ,MAAA;AAAA,IAChB,QAAQ,OAAA,CAAQ;AAAA,GACjB,CAAA;AACD,EAAA,MAAM,WAAW,KAAA,CAAM,WAAA,CAAY,MAAA,GAAS,KAAA,CAAM,YAAY,MAAA,GAAS,CAAA;AACvE,EAAA,OAAO,EAAE,OAAO,QAAA,EAAS;AAC3B;;;ACvDA,IAAM,aAAA,GAAgB,CAAC,kBAAA,EAAoB,kBAAA,EAAoB,oBAAoB,CAAA;AACnF,IAAM,aAAA,GAAgB,CAAC,mBAAA,EAAqB,mBAAA,EAAqB,uBAAuB,CAAA;AACxF,IAAM,UAAA,GAAa,uBAAA;AAMnB,eAAsB,SAAA,CAAU,OAAA,GAAyB,EAAC,EAGvD;AACD,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,GAAA,IAAO,OAAA,CAAQ,GAAA;AACnC,EAAA,MAAM,SAAwB,EAAC;AAG/B,EAAA,MAAM,WAAW,GAAA,CAAI,kBAAA;AACrB,EAAA,IAAI,CAAC,QAAA,EAAU;AACb,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,IAAA,EAAM,mBAAA;AAAA,MACN,EAAA,EAAI,KAAA;AAAA,MACJ,MAAA,EAAQ;AAAA,KACT,CAAA;AAAA,EACH,CAAA,MAAA,IAAW,QAAA,KAAa,IAAA,IAAQ,QAAA,KAAa,IAAA,EAAM;AACjD,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,IAAA,EAAM,gBAAA;AAAA,MACN,EAAA,EAAI,KAAA;AAAA,MACJ,MAAA,EAAQ,uBAAuB,QAAQ,CAAA,2CAAA;AAAA,KACxC,CAAA;AAAA,EACH,CAAA,MAAO;AACL,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,IAAA,EAAM,gBAAA;AAAA,MACN,EAAA,EAAI,IAAA;AAAA,MACJ,MAAA,EAAQ,uBAAuB,QAAQ,CAAA,CAAA;AAAA,KACxC,CAAA;AAAA,EACH;AAGA,EAAA,KAAA,MAAW,KAAK,aAAA,EAAe;AAC7B,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,IAAA,EAAM,OAAO,CAAC,CAAA,CAAA;AAAA,MACd,EAAA,EAAI,OAAO,GAAA,CAAI,CAAC,MAAM,QAAA,IAAY,GAAA,CAAI,CAAC,CAAA,CAAG,MAAA,GAAS,CAAA;AAAA,MACnD,MAAA,EAAQ,GAAA,CAAI,CAAC,CAAA,GAAI,CAAA,EAAG,CAAC,CAAA,EAAA,EAAK,GAAA,CAAI,CAAC,CAAC,CAAA,CAAA,CAAA,GAAM,CAAA,EAAG,CAAC,CAAA,WAAA;AAAA,KAC3C,CAAA;AAAA,EACH;AAGA,EAAA,MAAM,OAAA,GAAU,QAAA,KAAa,IAAA,GAAO,UAAA,GAAc,IAAI,iBAAA,IAAqB,QAAA;AAC3E,EAAA,IAAI,YAAY,UAAA,EAAY;AAC1B,IAAA,KAAA,MAAW,KAAK,aAAA,EAAe;AAC7B,MAAA,MAAA,CAAO,IAAA,CAAK;AAAA,QACV,IAAA,EAAM,OAAO,CAAC,CAAA,CAAA;AAAA,QACd,EAAA,EAAI,OAAO,GAAA,CAAI,CAAC,MAAM,QAAA,IAAY,GAAA,CAAI,CAAC,CAAA,CAAG,MAAA,GAAS,CAAA;AAAA,QACnD,MAAA,EAAQ,GAAA,CAAI,CAAC,CAAA,GAAI,CAAA,EAAG,CAAC,CAAA,EAAA,EAAK,GAAA,CAAI,CAAC,CAAC,CAAA,CAAA,CAAA,GAAM,CAAA,EAAG,CAAC,CAAA,2CAAA;AAAA,OAC3C,CAAA;AAAA,IACH;AAAA,EACF,CAAA,MAAA,IAAW,YAAY,QAAA,EAAU;AAC/B,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,IAAA,EAAM,OAAO,UAAU,CAAA,CAAA;AAAA,MACvB,EAAA,EAAI,OAAO,GAAA,CAAI,UAAU,MAAM,QAAA,IAAY,GAAA,CAAI,UAAU,CAAA,CAAG,MAAA,GAAS,CAAA;AAAA,MACrE,MAAA,EAAQ,GAAA,CAAI,UAAU,CAAA,IAAK,GAAG,UAAU,CAAA,yCAAA;AAAA,KACzC,CAAA;AAAA,EACH;AAGA,EAAA,IAAI,OAAA,CAAQ,OAAA,KAAY,OAAA,KAAY,UAAA,IAAc,YAAY,QAAA,CAAA,EAAW;AACvE,IAAA,MAAM,MAAA,GAAS,MAAM,OAAA,CAAQ,OAAA,CAAQ,OAAO,CAAA;AAC5C,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,IAAA,EAAM,GAAG,OAAO,CAAA,UAAA,CAAA;AAAA,MAChB,IAAI,MAAA,CAAO,EAAA;AAAA,MACX,QAAQ,MAAA,CAAO;AAAA,KAChB,CAAA;AAAA,EACH;AAGA,EAAA,IAAI,OAAA,CAAQ,WAAA,IAAe,GAAA,CAAI,gBAAA,IAAoB,IAAI,gBAAA,EAAkB;AACvE,IAAA,MAAM,SAAS,MAAM,OAAA,CAAQ,YAAY,GAAA,CAAI,gBAAA,EAAkB,IAAI,gBAAgB,CAAA;AACnF,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,IAAA,EAAM,kBAAA;AAAA,MACN,IAAI,MAAA,CAAO,EAAA;AAAA,MACX,QAAQ,MAAA,CAAO;AAAA,KAChB,CAAA;AAAA,EACH;AAEA,EAAA,MAAM,QAAQ,MAAA,CAAO,KAAA,CAAM,CAAC,CAAA,KAAM,EAAE,EAAE,CAAA;AACtC,EAAA,OAAO,EAAE,QAAQ,KAAA,EAAM;AACzB;AAKO,SAAS,mBAAmB,MAAA,EAA4C;AAC7E,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,CAAC,CAAA,KAAM;AAC9B,IAAA,MAAM,IAAA,GAAO,CAAA,CAAE,EAAA,GAAK,QAAA,GAAM,QAAA;AAC1B,IAAA,OAAO,CAAA,EAAA,EAAK,IAAI,CAAA,CAAA,EAAI,CAAA,CAAE,IAAA,CAAK,OAAO,EAAE,CAAC,CAAA,CAAA,EAAI,CAAA,CAAE,MAAM,CAAA,CAAA;AAAA,EACnD,CAAC,CAAA;AACD,EAAA,MAAM,SAAS,MAAA,CAAO,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,EAAE,CAAA,CAAE,MAAA;AAC1C,EAAA,MAAM,QAAQ,MAAA,CAAO,MAAA;AACrB,EAAA,OAAO,CAAC,CAAA,kBAAA,EAAqB,MAAM,CAAA,CAAA,EAAI,KAAK,CAAA,cAAA,CAAA,EAAkB,GAAG,KAAK,CAAA,CAAE,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA;AACvF;;;AChHO,IAAM,OAAA,GAAU;AAEhB,SAAS,SAAA,CAAU,UAAkB,OAAA,EAAiB;AAC3D,EAAA,OAAO,kBAAkB,OAAO;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;AAuBlC;AAcA,eAAsB,SACpB,IAAA,EACA,KAAA,GAAuB,EAAC,EACxB,UAAkB,OAAA,EAC6C;AAC/D,EAAA,MAAM,GAAA,GAAM,EAAE,MAAA,EAAQ,EAAA,EAAI,QAAQ,EAAA,EAAG;AACrC,EAAA,MAAM,QAAA,GAAW,CAAC,CAAA,KAAe,GAAA,CAAI,MAAA,IAAU,CAAA;AAC/C,EAAA,MAAM,QAAA,GAAW,CAAC,CAAA,KAAe,GAAA,CAAI,MAAA,IAAU,CAAA;AAC/C,EAAA,MAAM,OAAA,GAAU,KAAK,CAAC,CAAA;AAEtB,EAAA,QAAQ,OAAA;AAAS,IACf,KAAK,WAAA;AAAA,IACL,KAAK,IAAA;AACH,MAAA,QAAA,CAAS,aAAa,OAAO;AAAA,CAAI,CAAA;AACjC,MAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,IAE/B,KAAK,QAAA;AAAA,IACL,KAAK,IAAA;AACH,MAAA,QAAA,CAAS,SAAA,CAAU,OAAO,CAAC,CAAA;AAC3B,MAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,IAE/B,KAAK,MAAA;AACH,MAAA,QAAA,CAAS,SAAA,CAAU,OAAO,CAAC,CAAA;AAC3B,MAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,IAE/B,KAAK,SAAA,EAAW;AACd,MAAA,MAAM,MAAA,GAAS,gBAAA,CAAiB,IAAA,CAAK,KAAA,CAAM,CAAC,CAAC,CAAA;AAC7C,MAAA,IAAI,WAAW,MAAA,EAAQ;AACrB,QAAA,QAAA,CAAS,CAAA,mBAAA,EAAsB,OAAO,KAAK;AAAA,CAAI,CAAA;AAC/C,QAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,MAC/B;AACA,MAAA,IAAI;AACF,QAAA,MAAM,MAAA,GAAS,MAAM,UAAA,CAAW,MAAA,EAAQ,MAAM,OAAO,CAAA;AACrD,QAAA,QAAA;AAAA,UACE,WAAW,MAAA,CAAO,OAAA,CAAQ,MAAM,CAAA,eAAA,EAAkB,MAAA,CAAO,QAAQ,MAAM,CAAA;AAAA;AAAA,SACzE;AACA,QAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,MAC/B,SAAS,GAAA,EAAK;AACZ,QAAA,QAAA,CAAS,CAAA,mBAAA,EAAuB,IAAc,OAAO;AAAA,CAAI,CAAA;AACzD,QAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,MAC/B;AAAA,IACF;AAAA,IAEA,KAAK,WAAA,EAAa;AAChB,MAAA,MAAM,MAAA,GAAS,kBAAA,CAAmB,IAAA,CAAK,KAAA,CAAM,CAAC,CAAC,CAAA;AAC/C,MAAA,IAAI,WAAW,MAAA,EAAQ;AACrB,QAAA,QAAA,CAAS,CAAA,qBAAA,EAAwB,OAAO,KAAK;AAAA,CAAI,CAAA;AACjD,QAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,MAC/B;AACA,MAAA,IAAI;AACF,QAAA,MAAM,MAAA,GAAS,MAAM,YAAA,CAAa,MAAA,EAAQ,MAAM,SAAS,CAAA;AACzD,QAAA,QAAA;AAAA,UACE,CAAA,OAAA,EAAU,OAAO,MAAM,CAAA,oBAAA,EAAuB,OAAO,KAAA,CAAM,WAAA,CAAY,MAAM,CAAA,aAAA,EAC5D,MAAA,CAAO,MAAM,WAAA,CAAY,MAAM,UACrC,MAAA,CAAO,KAAA,CAAM,UAAU,CAAA,CAAA,EAC7B,MAAA,CAAO,MAAA,GAAS,WAAA,GAAc,EAAE;AAAA;AAAA,SACvC;AACA,QAAA,OAAO,EAAE,QAAA,EAAU,MAAA,CAAO,WAAW,CAAA,GAAI,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,MACrD,SAAS,GAAA,EAAK;AACZ,QAAA,QAAA,CAAS,CAAA,qBAAA,EAAyB,IAAc,OAAO;AAAA,CAAI,CAAA;AAC3D,QAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,MAC/B;AAAA,IACF;AAAA,IAEA,KAAK,QAAA,EAAU;AACb,MAAA,IAAI;AACF,QAAA,MAAM,EAAE,MAAA,EAAQ,KAAA,EAAM,GAAI,MAAM,SAAA,EAAU;AAC1C,QAAA,QAAA,CAAS,kBAAA,CAAmB,MAAM,CAAC,CAAA;AACnC,QAAA,OAAO,EAAE,QAAA,EAAU,KAAA,GAAQ,CAAA,GAAI,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,MAC3C,SAAS,GAAA,EAAK;AACZ,QAAA,QAAA,CAAS,CAAA,kBAAA,EAAsB,IAAc,OAAO;AAAA,CAAI,CAAA;AACxD,QAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA,MAC/B;AAAA,IACF;AAAA,IAEA;AACE,MAAA,QAAA,CAAS,oBAAoB,OAAO;AAAA;AAAA,CAA6B,CAAA;AACjE,MAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAG,GAAA,EAAI;AAAA;AAEnC","file":"index.js","sourcesContent":["/**\n * `file-next migrate` — apply pending metadata schema migrations.\n *\n * v0.1 scope: prints the migration plan and reports what would run.\n * Actual migration execution is delegated to the adapter package's\n * migrator (Drizzle for postgres/sqlite). This CLI is the orchestrator\n * that resolves the adapter from the config and invokes it.\n *\n * Usage:\n * file-next migrate --adapter=postgres\n * file-next migrate --adapter=sqlite\n *\n * Exit codes:\n * 0 — migrations applied (or none pending)\n * 1 — configuration error (missing / unknown adapter)\n * 2 — migration failed (the adapter threw)\n */\nimport { parseArgs } from \"node:util\";\n\nexport interface MigrateOptions {\n readonly adapter: \"postgres\" | \"sqlite\";\n}\n\nexport function parseMigrateArgs(argv: ReadonlyArray<string>): MigrateOptions | { error: string } {\n let parsed: ReturnType<typeof parseArgs>;\n try {\n parsed = parseArgs({\n args: argv as string[],\n options: {\n adapter: { type: \"string\", short: \"a\" },\n },\n strict: true,\n allowPositionals: false,\n });\n } catch (err) {\n return { error: (err as Error).message };\n }\n\n const adapter = parsed.values.adapter;\n if (adapter !== \"postgres\" && adapter !== \"sqlite\") {\n return { error: \"--adapter must be either 'postgres' or 'sqlite'\" };\n }\n return { adapter };\n}\n\nexport interface MigrateResult {\n readonly applied: ReadonlyArray<string>;\n readonly pending: ReadonlyArray<string>;\n}\n\n/**\n * Resolve and run migrations against the chosen adapter.\n *\n * v0.1 implementation: returns a static plan rather than touching\n * a real database. The hook for `runAdapterMigrations` is the\n * integration point for v0.2 (which will load Drizzle's migrator\n * dynamically based on the adapter choice).\n */\nexport interface MigrateHooks {\n /** Resolves the migrator implementation. May throw on unsupported adapter. */\n resolveMigrator: (adapter: MigrateOptions[\"adapter\"]) => Promise<{\n listPending: () => Promise<ReadonlyArray<string>>;\n apply: () => Promise<ReadonlyArray<string>>;\n }>;\n}\n\nexport async function runMigrate(\n options: MigrateOptions,\n hooks: MigrateHooks = {\n // Default no-op migrator so the CLI can be smoke-tested without\n // a real DB. The real adapter wiring lives in the consumer's\n // app where drizzle-orm is a runtime dep.\n resolveMigrator: async () => ({\n listPending: async () => [],\n apply: async () => [],\n }),\n },\n): Promise<MigrateResult> {\n const migrator = await hooks.resolveMigrator(options.adapter);\n const pending = await migrator.listPending();\n const applied = await migrator.apply();\n return { applied, pending };\n}\n","/**\n * `file-next reconcile` — find and (optionally) fix drift between\n * the S3 bucket and the metadata index.\n *\n * Usage:\n * file-next reconcile --tenant=acme\n * file-next reconcile --tenant=acme --dry-run\n *\n * Exit codes:\n * 0 — no drift detected (or all drift fixed)\n * 1 — drift detected AND not fixed (dry-run mode)\n * 2 — configuration / connectivity error\n */\nimport { parseArgs } from \"node:util\";\n\nexport interface ReconcileOptions {\n readonly tenant: string;\n readonly dryRun: boolean;\n}\n\nexport function parseReconcileArgs(\n argv: ReadonlyArray<string>,\n): ReconcileOptions | { error: string } {\n let parsed: ReturnType<typeof parseArgs>;\n try {\n parsed = parseArgs({\n args: argv as string[],\n options: {\n tenant: { type: \"string\", short: \"t\" },\n \"dry-run\": { type: \"boolean\", default: false },\n },\n strict: true,\n allowPositionals: false,\n });\n } catch (err) {\n return { error: (err as Error).message };\n }\n\n if (typeof parsed.values.tenant !== \"string\" || parsed.values.tenant.length === 0) {\n return { error: \"--tenant is required\" };\n }\n return {\n tenant: parsed.values.tenant,\n dryRun: parsed.values[\"dry-run\"] === true,\n };\n}\n\nexport interface DriftReport {\n readonly missingInS3: ReadonlyArray<string>;\n readonly orphansInS3: ReadonlyArray<string>;\n readonly fixedCount: number;\n}\n\nexport interface ReconcileResult {\n readonly drift: DriftReport;\n readonly hadDrift: boolean;\n}\n\n/**\n * Run a reconcile cycle for a tenant.\n *\n * v0.1: the actual sync logic is provided via the `runSync` hook so\n * this CLI is testable without a real Postgres / S3 backend. The\n * consumer wires the real `reconcile()` from `@vryzel/file-next/sync` into\n * the hook when running the CLI against production.\n */\nexport interface ReconcileHooks {\n runSync: (input: {\n tenant: string;\n dryRun: boolean;\n }) => Promise<DriftReport>;\n}\n\nexport async function runReconcile(\n options: ReconcileOptions,\n hooks: ReconcileHooks = {\n runSync: async () => ({\n missingInS3: [],\n orphansInS3: [],\n fixedCount: 0,\n }),\n },\n): Promise<ReconcileResult> {\n const drift = await hooks.runSync({\n tenant: options.tenant,\n dryRun: options.dryRun,\n });\n const hadDrift = drift.missingInS3.length + drift.orphansInS3.length > 0;\n return { drift, hadDrift };\n}\n","/**\n * `file-next doctor` — diagnose the local environment for file-next.\n *\n * Checks (each pass/fail):\n * - Required env vars present (FILE_NEXT_BUCKET, FILE_NEXT_REGION,\n * FILE_NEXT_PROVIDER, and adapter-specific creds).\n * - Metadata DB reachable (postgres / sqlite path resolves).\n * - S3 bucket reachable (HEAD on the configured bucket returns 200\n * or 403 — not network error).\n *\n * Usage:\n * file-next doctor\n *\n * Exit codes:\n * 0 — all checks pass\n * 1 — one or more checks failed (printed to stderr)\n * 2 — invalid configuration (e.g. unknown provider)\n */\n\nexport interface DoctorCheck {\n readonly name: string;\n readonly ok: boolean;\n readonly detail: string;\n}\n\nexport interface DoctorOptions {\n /** Env vars to validate. The CLI uses process.env by default. */\n readonly env?: NodeJS.ProcessEnv;\n /** Optional callback to test DB reachability. */\n readonly probeDb?: (provider: \"postgres\" | \"sqlite\") => Promise<{ ok: boolean; detail: string }>;\n /** Optional callback to test S3 bucket reachability. */\n readonly probeBucket?: (bucket: string, region: string) => Promise<{ ok: boolean; detail: string }>;\n}\n\nconst REQUIRED_VARS = [\"FILE_NEXT_BUCKET\", \"FILE_NEXT_REGION\", \"FILE_NEXT_PROVIDER\"] as const;\nconst POSTGRES_VARS = [\"FILE_NEXT_PG_HOST\", \"FILE_NEXT_PG_USER\", \"FILE_NEXT_PG_DATABASE\"] as const;\nconst SQLITE_VAR = \"FILE_NEXT_SQLITE_PATH\";\n\n/**\n * Run all checks. Returns a list of { name, ok, detail } records.\n * The CLI formats and prints them; the caller decides the exit code.\n */\nexport async function runDoctor(options: DoctorOptions = {}): Promise<{\n checks: ReadonlyArray<DoctorCheck>;\n allOk: boolean;\n}> {\n const env = options.env ?? process.env;\n const checks: DoctorCheck[] = [];\n\n // 1. Provider declared\n const provider = env.FILE_NEXT_PROVIDER;\n if (!provider) {\n checks.push({\n name: \"provider declared\",\n ok: false,\n detail: \"FILE_NEXT_PROVIDER is not set (expected 's3' or 'r2')\",\n });\n } else if (provider !== \"s3\" && provider !== \"r2\") {\n checks.push({\n name: \"provider valid\",\n ok: false,\n detail: `FILE_NEXT_PROVIDER=\"${provider}\" is not recognized (expected 's3' or 'r2')`,\n });\n } else {\n checks.push({\n name: \"provider valid\",\n ok: true,\n detail: `FILE_NEXT_PROVIDER=\"${provider}\"`,\n });\n }\n\n // 2. Required env vars present\n for (const v of REQUIRED_VARS) {\n checks.push({\n name: `env ${v}`,\n ok: typeof env[v] === \"string\" && env[v]!.length > 0,\n detail: env[v] ? `${v}=\"${env[v]}\"` : `${v} is missing`,\n });\n }\n\n // 3. Adapter-specific vars\n const adapter = provider === \"r2\" ? \"postgres\" : (env.FILE_NEXT_ADAPTER ?? \"memory\");\n if (adapter === \"postgres\") {\n for (const v of POSTGRES_VARS) {\n checks.push({\n name: `env ${v}`,\n ok: typeof env[v] === \"string\" && env[v]!.length > 0,\n detail: env[v] ? `${v}=\"${env[v]}\"` : `${v} is missing (required for postgres adapter)`,\n });\n }\n } else if (adapter === \"sqlite\") {\n checks.push({\n name: `env ${SQLITE_VAR}`,\n ok: typeof env[SQLITE_VAR] === \"string\" && env[SQLITE_VAR]!.length > 0,\n detail: env[SQLITE_VAR] ?? `${SQLITE_VAR} is missing (required for sqlite adapter)`,\n });\n }\n\n // 4. DB reachability (only if probe provided)\n if (options.probeDb && (adapter === \"postgres\" || adapter === \"sqlite\")) {\n const result = await options.probeDb(adapter);\n checks.push({\n name: `${adapter} reachable`,\n ok: result.ok,\n detail: result.detail,\n });\n }\n\n // 5. S3 reachability (only if probe provided)\n if (options.probeBucket && env.FILE_NEXT_BUCKET && env.FILE_NEXT_REGION) {\n const result = await options.probeBucket(env.FILE_NEXT_BUCKET, env.FILE_NEXT_REGION);\n checks.push({\n name: \"bucket reachable\",\n ok: result.ok,\n detail: result.detail,\n });\n }\n\n const allOk = checks.every((c) => c.ok);\n return { checks, allOk };\n}\n\n/**\n * Pretty-print the doctor report to stdout. Used by the CLI dispatcher.\n */\nexport function formatDoctorReport(checks: ReadonlyArray<DoctorCheck>): string {\n const lines = checks.map((c) => {\n const mark = c.ok ? \"✓\" : \"✗\";\n return ` ${mark} ${c.name.padEnd(28)} ${c.detail}`;\n });\n const passed = checks.filter((c) => c.ok).length;\n const total = checks.length;\n return [`file-next doctor: ${passed}/${total} checks passed`, ...lines].join(\"\\n\") + \"\\n\";\n}\n","/**\n * `file-next` CLI library — exports `dispatch()` and command parsers\n * for programmatic use AND for the bin shim.\n *\n * The actual binary entry point is `./bin.mjs` (in this directory;\n * tsup writes it to `dist/bin.mjs`). It imports `dispatch` from\n * here and handles the process.exit / stdout / stderr wiring.\n *\n * Why split it this way:\n * - Tests can import from `@/index` (this file) without triggering\n * any process.exit side effects.\n * - The bin file is tiny (3 lines) and exists solely to translate\n * `dispatch()`'s return value into the correct process exit +\n * stdout/stderr writes.\n * - No brittle `import.meta.url === process.argv[1]` heuristics\n * (those break on macOS symlinks like /tmp → /private/tmp).\n */\nimport { parseMigrateArgs, runMigrate, type MigrateHooks } from \"./migrate.js\";\nimport { parseReconcileArgs, runReconcile, type ReconcileHooks } from \"./reconcile.js\";\nimport { runDoctor, formatDoctorReport } from \"./doctor.js\";\n\nexport const VERSION = \"0.4.0\";\n\nexport function printHelp(version: string = VERSION): string {\n return `file-next CLI v${version}\n\nUsage:\n file-next <command> [options]\n\nCommands:\n migrate Apply pending metadata schema migrations\n --adapter=<postgres|sqlite> (required)\n reconcile Detect and (optionally) fix S3 vs metadata drift\n --tenant=<id> (required)\n --dry-run (default: false)\n doctor Diagnose the local environment\n (checks env vars + adapter reachability)\n\nFlags:\n --version Print the CLI version\n --help Print this help\n\nExit codes:\n 0 success\n 1 expected failure (drift detected, doctor found an issue)\n 2 configuration / runtime error\n`;\n}\n\nexport interface DispatchHooks {\n readonly migrate?: MigrateHooks;\n readonly reconcile?: ReconcileHooks;\n}\n\n/**\n * Dispatch a CLI invocation to the right command and return the exit code.\n *\n * Does NOT call `process.exit` — that is the caller's job (the bin\n * script at the bottom of this file). Pure dispatch logic so it\n * can be unit-tested without spawning a subprocess.\n */\nexport async function dispatch(\n argv: ReadonlyArray<string>,\n hooks: DispatchHooks = {},\n version: string = VERSION,\n): Promise<{ exitCode: number; stdout: string; stderr: string }> {\n const out = { stdout: \"\", stderr: \"\" };\n const writeOut = (s: string) => (out.stdout += s);\n const writeErr = (s: string) => (out.stderr += s);\n const command = argv[0];\n\n switch (command) {\n case \"--version\":\n case \"-v\":\n writeOut(`file-next ${version}\\n`);\n return { exitCode: 0, ...out };\n\n case \"--help\":\n case \"-h\":\n writeOut(printHelp(version));\n return { exitCode: 0, ...out };\n\n case undefined:\n writeOut(printHelp(version));\n return { exitCode: 1, ...out };\n\n case \"migrate\": {\n const parsed = parseMigrateArgs(argv.slice(1));\n if (\"error\" in parsed) {\n writeErr(`file-next migrate: ${parsed.error}\\n`);\n return { exitCode: 2, ...out };\n }\n try {\n const result = await runMigrate(parsed, hooks.migrate);\n writeOut(\n `Applied ${result.applied.length} migration(s); ${result.pending.length} pending.\\n`,\n );\n return { exitCode: 0, ...out };\n } catch (err) {\n writeErr(`file-next migrate: ${(err as Error).message}\\n`);\n return { exitCode: 2, ...out };\n }\n }\n\n case \"reconcile\": {\n const parsed = parseReconcileArgs(argv.slice(1));\n if (\"error\" in parsed) {\n writeErr(`file-next reconcile: ${parsed.error}\\n`);\n return { exitCode: 2, ...out };\n }\n try {\n const result = await runReconcile(parsed, hooks.reconcile);\n writeOut(\n `tenant=${parsed.tenant} drift: missingInS3=${result.drift.missingInS3.length} ` +\n `orphansInS3=${result.drift.orphansInS3.length} ` +\n `fixed=${result.drift.fixedCount} ` +\n `${parsed.dryRun ? \"(dry-run)\" : \"\"}\\n`,\n );\n return { exitCode: result.hadDrift ? 1 : 0, ...out };\n } catch (err) {\n writeErr(`file-next reconcile: ${(err as Error).message}\\n`);\n return { exitCode: 2, ...out };\n }\n }\n\n case \"doctor\": {\n try {\n const { checks, allOk } = await runDoctor();\n writeOut(formatDoctorReport(checks));\n return { exitCode: allOk ? 0 : 1, ...out };\n } catch (err) {\n writeErr(`file-next doctor: ${(err as Error).message}\\n`);\n return { exitCode: 2, ...out };\n }\n }\n\n default:\n writeErr(`Unknown command: ${command}\\nRun 'file-next --help'.\\n`);\n return { exitCode: 2, ...out };\n }\n}\n\n// No top-level process.exit / dispatch here. The bin shim\n// (`bin.mjs` in this directory; emitted to `dist/bin.mjs` by tsup)\n// is the only entry point that calls `dispatch()` and writes\n// its stdout/stderr to the actual process streams.\n"]}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@vryzel/file-next-cli",
3
+ "version": "0.4.0",
4
+ "description": "CLI for file-next — migrate metadata schemas, reconcile drift between S3 and the metadata index, and diagnose environment / connectivity issues.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": "https://github.com/Vryzel/file-next.git",
8
+ "publishConfig": {
9
+ "access": "public",
10
+ "registry": "https://registry.npmjs.org"
11
+ },
12
+ "sideEffects": false,
13
+ "bin": {
14
+ "file-next": "./dist/bin.js"
15
+ },
16
+ "main": "./dist/index.cjs",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "dependencies": {
24
+ "@vryzel/file-next": "0.4.0"
25
+ },
26
+ "devDependencies": {
27
+ "tsup": "^8.3.5",
28
+ "typescript": "^5.6.0",
29
+ "vitest": "^2.1.5"
30
+ },
31
+ "keywords": [
32
+ "cli",
33
+ "file-next",
34
+ "migration",
35
+ "reconcile",
36
+ "doctor"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "dev": "tsup --watch",
41
+ "typecheck": "tsc --noEmit -p tsconfig.json",
42
+ "test": "vitest",
43
+ "test:run": "vitest run"
44
+ }
45
+ }