@muthuishere/vsync 0.3.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 +260 -0
- package/bin/docs.ts +20 -0
- package/bin/export.ts +92 -0
- package/bin/import.ts +103 -0
- package/bin/init.ts +259 -0
- package/bin/pull.ts +103 -0
- package/bin/push.ts +85 -0
- package/bin/sync.ts +279 -0
- package/bin/versions.ts +108 -0
- package/bin/vsync.ts +102 -0
- package/package.json +46 -0
- package/src/archive.ts +89 -0
- package/src/argv.ts +34 -0
- package/src/backup.ts +74 -0
- package/src/codec.ts +21 -0
- package/src/crypto.ts +71 -0
- package/src/defaults.ts +91 -0
- package/src/envconfig.ts +179 -0
- package/src/envfile.ts +95 -0
- package/src/keychain.ts +65 -0
- package/src/manifest.ts +38 -0
- package/src/passphrase.ts +39 -0
- package/src/prompt.ts +133 -0
- package/src/repo.ts +89 -0
- package/src/repoconfig.ts +163 -0
- package/src/s3.ts +25 -0
- package/src/sharefile.ts +100 -0
- package/src/syncpool.ts +47 -0
- package/src/templates/docs.md.ts +89 -0
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// prompt.ts — tiny TTY input helpers used by every CLI subcommand so the
|
|
2
|
+
// same verb can be driven entirely by flags ("--bucket=foo --region=…")
|
|
3
|
+
// for scripting, OR by interactive prompts when flags are missing.
|
|
4
|
+
//
|
|
5
|
+
// Three helpers:
|
|
6
|
+
// askText / askBool / askSecret — promise-returning readline calls
|
|
7
|
+
// resolveOrAsk(value, question) — uses the provided value or prompts
|
|
8
|
+
// confirmYes(question) — small y/n with a default
|
|
9
|
+
//
|
|
10
|
+
// We deliberately avoid third-party deps. Bun ships with a global
|
|
11
|
+
// `prompt()` (Web standard) for plain text. For hidden input we toggle
|
|
12
|
+
// raw mode on process.stdin and read char-by-char so passphrases don't
|
|
13
|
+
// echo to the screen.
|
|
14
|
+
|
|
15
|
+
import { stdin, stdout } from "node:process";
|
|
16
|
+
|
|
17
|
+
/** True when stdin is a real TTY (so prompts make sense). */
|
|
18
|
+
export function isTty(): boolean {
|
|
19
|
+
return Boolean((stdin as any).isTTY);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Bun's global prompt(), with a default-value fallback when offered. */
|
|
23
|
+
export function askText(label: string, defaultValue?: string): string {
|
|
24
|
+
const promptStr =
|
|
25
|
+
defaultValue !== undefined ? `${label} [${defaultValue}]: ` : `${label}: `;
|
|
26
|
+
const v = (globalThis as any).prompt(promptStr);
|
|
27
|
+
if (v === null || v === undefined) {
|
|
28
|
+
throw new Error("aborted (no input)");
|
|
29
|
+
}
|
|
30
|
+
const trimmed = String(v).trim();
|
|
31
|
+
return trimmed || defaultValue || "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** y/n prompt; honours a default if user just hits enter. */
|
|
35
|
+
export function askBool(label: string, defaultValue: boolean): boolean {
|
|
36
|
+
const def = defaultValue ? "Y/n" : "y/N";
|
|
37
|
+
const v = (globalThis as any).prompt(`${label} [${def}]: `);
|
|
38
|
+
if (v === null || v === undefined) return defaultValue;
|
|
39
|
+
const t = String(v).trim().toLowerCase();
|
|
40
|
+
if (!t) return defaultValue;
|
|
41
|
+
return t.startsWith("y");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Hidden input — toggles raw mode on TTY and masks each keystroke. */
|
|
45
|
+
export async function askSecret(label: string): Promise<string> {
|
|
46
|
+
if (!isTty()) {
|
|
47
|
+
// Non-TTY (piped stdin) — just read a line.
|
|
48
|
+
return await readLineFromStdin(label);
|
|
49
|
+
}
|
|
50
|
+
stdout.write(label + ": ");
|
|
51
|
+
return await new Promise<string>((resolve, reject) => {
|
|
52
|
+
const buf: string[] = [];
|
|
53
|
+
(stdin as any).setRawMode?.(true);
|
|
54
|
+
(stdin as any).resume?.();
|
|
55
|
+
stdin.setEncoding("utf8");
|
|
56
|
+
const onData = (chunk: string) => {
|
|
57
|
+
for (const ch of chunk) {
|
|
58
|
+
if (ch === "\n" || ch === "\r" || ch === "") {
|
|
59
|
+
(stdin as any).setRawMode?.(false);
|
|
60
|
+
stdin.off("data", onData);
|
|
61
|
+
stdin.pause();
|
|
62
|
+
stdout.write("\n");
|
|
63
|
+
resolve(buf.join("").trim());
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (ch === "") {
|
|
67
|
+
(stdin as any).setRawMode?.(false);
|
|
68
|
+
stdin.off("data", onData);
|
|
69
|
+
stdin.pause();
|
|
70
|
+
stdout.write("\n");
|
|
71
|
+
reject(new Error("aborted"));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (ch === "" || ch === "\b") {
|
|
75
|
+
if (buf.length) {
|
|
76
|
+
buf.pop();
|
|
77
|
+
stdout.write("\b \b");
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
buf.push(ch);
|
|
82
|
+
stdout.write("*");
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
stdin.on("data", onData);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readLineFromStdin(label: string): Promise<string> {
|
|
90
|
+
if (label) stdout.write(label + ": ");
|
|
91
|
+
return await new Promise<string>((resolve) => {
|
|
92
|
+
let buf = "";
|
|
93
|
+
stdin.setEncoding("utf8");
|
|
94
|
+
const onData = (chunk: string) => {
|
|
95
|
+
buf += chunk;
|
|
96
|
+
const nl = buf.indexOf("\n");
|
|
97
|
+
if (nl >= 0) {
|
|
98
|
+
stdin.off("data", onData);
|
|
99
|
+
resolve(buf.slice(0, nl).trim());
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
stdin.once("end", () => resolve(buf.trim()));
|
|
103
|
+
stdin.on("data", onData);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Use the provided value if defined+non-empty, otherwise prompt. Throws
|
|
108
|
+
* if not a TTY and no value was given (can't prompt non-interactively). */
|
|
109
|
+
export function resolveOrAsk(
|
|
110
|
+
value: string | undefined,
|
|
111
|
+
label: string,
|
|
112
|
+
defaultValue?: string,
|
|
113
|
+
): string {
|
|
114
|
+
if (value !== undefined && value !== "") return value;
|
|
115
|
+
if (!isTty()) {
|
|
116
|
+
if (defaultValue !== undefined) return defaultValue;
|
|
117
|
+
throw new Error(
|
|
118
|
+
`missing ${label} and stdin is not a TTY — pass --${label.toLowerCase().replace(/\s+/g, "-")}=… on the command line`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return askText(label, defaultValue);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Yes/no confirmation. If --yes flag pre-set, returns true silently. */
|
|
125
|
+
export function confirmYes(label: string, preApproved: boolean): boolean {
|
|
126
|
+
if (preApproved) return true;
|
|
127
|
+
if (!isTty()) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`${label} requires interactive confirmation — pass --yes to bypass`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return askBool(label, false);
|
|
133
|
+
}
|
package/src/repo.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// repo.ts — utilities for working out which repo we're running in.
|
|
2
|
+
//
|
|
3
|
+
// Two things consumers need:
|
|
4
|
+
// - getRepoRoot() — the filesystem path of the repo top-level
|
|
5
|
+
// - getRepoName() — a short identifier used as the namespace for the
|
|
6
|
+
// config file and keychain entry
|
|
7
|
+
//
|
|
8
|
+
// Repo-name precedence (first match wins):
|
|
9
|
+
// 1. Explicit override (e.g. `--repo=foo` flag passed through)
|
|
10
|
+
// 2. SECRETS_SYNC_REPO env var
|
|
11
|
+
// 3. `name` from package.json at the repo root, with any leading scope
|
|
12
|
+
// stripped (e.g. "@muthuishere/vsync" → "vsync")
|
|
13
|
+
// 4. basename of the git remote / git toplevel (e.g. "reqsume")
|
|
14
|
+
// 5. basename of process.cwd() as a last resort
|
|
15
|
+
//
|
|
16
|
+
// All four file-paths and the keychain account are derived from the
|
|
17
|
+
// resulting name, so it should be stable across machines for the same
|
|
18
|
+
// repo.
|
|
19
|
+
|
|
20
|
+
import * as fs from "node:fs/promises";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
|
|
23
|
+
/** Filesystem path of the repo root (git toplevel, else cwd). */
|
|
24
|
+
export async function getRepoRoot(): Promise<string> {
|
|
25
|
+
const proc = Bun.spawn(["git", "rev-parse", "--show-toplevel"], {
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
});
|
|
29
|
+
const code = await proc.exited;
|
|
30
|
+
if (code === 0) {
|
|
31
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
32
|
+
}
|
|
33
|
+
return process.cwd();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type RepoNameOptions = {
|
|
37
|
+
/** Caller-supplied override (e.g. parsed from --repo=… flag). */
|
|
38
|
+
override?: string;
|
|
39
|
+
/** Repo root override (mostly for tests). Defaults to getRepoRoot(). */
|
|
40
|
+
root?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the canonical repo name used by config file paths and keychain
|
|
45
|
+
* entries. See the precedence list at the top of this file.
|
|
46
|
+
*/
|
|
47
|
+
export async function getRepoName(
|
|
48
|
+
opts: RepoNameOptions = {},
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const fromOverride = sanitize(opts.override);
|
|
51
|
+
if (fromOverride) return fromOverride;
|
|
52
|
+
|
|
53
|
+
const fromEnv = sanitize(process.env.SECRETS_SYNC_REPO);
|
|
54
|
+
if (fromEnv) return fromEnv;
|
|
55
|
+
|
|
56
|
+
const root = opts.root ?? (await getRepoRoot());
|
|
57
|
+
|
|
58
|
+
const fromPkg = sanitize(await readPackageName(root));
|
|
59
|
+
if (fromPkg) return fromPkg;
|
|
60
|
+
|
|
61
|
+
const fromGit = sanitize(path.basename(root));
|
|
62
|
+
if (fromGit) return fromGit;
|
|
63
|
+
|
|
64
|
+
return sanitize(path.basename(process.cwd())) || "default";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readPackageName(root: string): Promise<string | null> {
|
|
68
|
+
try {
|
|
69
|
+
const buf = await fs.readFile(path.join(root, "package.json"), "utf8");
|
|
70
|
+
const parsed = JSON.parse(buf);
|
|
71
|
+
const name: unknown = parsed?.name;
|
|
72
|
+
if (typeof name !== "string" || !name) return null;
|
|
73
|
+
// Strip npm scope: "@scope/foo" → "foo"
|
|
74
|
+
const slash = name.indexOf("/");
|
|
75
|
+
return slash >= 0 ? name.slice(slash + 1) : name;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Allow letters, digits, underscore, hyphen, and dot. Strip anything else.
|
|
83
|
+
* Empty result returns null so callers fall through to the next source.
|
|
84
|
+
*/
|
|
85
|
+
function sanitize(value: string | null | undefined): string | null {
|
|
86
|
+
if (!value) return null;
|
|
87
|
+
const trimmed = value.trim().replace(/[^A-Za-z0-9._-]/g, "");
|
|
88
|
+
return trimmed || null;
|
|
89
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// repoconfig.ts — the on-disk per-(repo, env) half of a vsync config.
|
|
2
|
+
// Self-contained: holds everything push / pull / sync need at runtime,
|
|
3
|
+
// minus the encryption key (which lives in the OS keychain — see
|
|
4
|
+
// keychain.ts).
|
|
5
|
+
//
|
|
6
|
+
// Path: ${XDG_CONFIG_HOME:-$HOME/.config}/vsync/<repo>/env_<env>
|
|
7
|
+
//
|
|
8
|
+
// File mode 0600, parent dir 0700. Stored as gzip(JSON) — raw bytes,
|
|
9
|
+
// no base64 wrapper.
|
|
10
|
+
|
|
11
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
|
|
15
|
+
import type { S3Credentials } from "./s3";
|
|
16
|
+
import { vsyncBaseDir } from "./defaults";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* On-disk shape. Self-contained — push / pull / sync read this file and
|
|
20
|
+
* the keychain entry, nothing else. `loadEnvConfig` (envconfig.ts) just
|
|
21
|
+
* splices in the keychain key.
|
|
22
|
+
*
|
|
23
|
+
* `files.vaultFolder` overrides the default `infra/vault/<env>` for
|
|
24
|
+
* monorepos. `sync.gh.repo` / `sync.gcp.project` are routing config
|
|
25
|
+
* for the `vsync sync` fanout, written on first invocation.
|
|
26
|
+
*/
|
|
27
|
+
export type ConfigFile = {
|
|
28
|
+
version: 1;
|
|
29
|
+
s3: S3Credentials;
|
|
30
|
+
encryption: { salt: string };
|
|
31
|
+
files?: { vaultFolder?: string };
|
|
32
|
+
sync?: {
|
|
33
|
+
gh?: { repo: string };
|
|
34
|
+
gcp?: { project: string };
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Full path for a given (repo, env). env is lowercased; repo is taken as-is. */
|
|
39
|
+
export function configFilePath(repo: string, env: string): string {
|
|
40
|
+
if (!repo) throw new Error("repo is required");
|
|
41
|
+
if (!env) throw new Error("env is required");
|
|
42
|
+
return path.join(vsyncBaseDir(), repo, `env_${env.toLowerCase()}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Persist a ConfigFile. Creates the directory tree if missing. Uses 0700
|
|
47
|
+
* on directories and 0600 on the file so other local users on the
|
|
48
|
+
* machine can't read it.
|
|
49
|
+
*/
|
|
50
|
+
export async function saveConfigFile(
|
|
51
|
+
repo: string,
|
|
52
|
+
env: string,
|
|
53
|
+
cfg: ConfigFile,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
validateConfigFile(cfg);
|
|
56
|
+
const file = configFilePath(repo, env);
|
|
57
|
+
const dir = path.dirname(file);
|
|
58
|
+
|
|
59
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
60
|
+
try {
|
|
61
|
+
await fs.chmod(dir, 0o700);
|
|
62
|
+
} catch {
|
|
63
|
+
// Non-fatal; some filesystems (e.g. Windows) don't honour chmod.
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const json = JSON.stringify(cfg);
|
|
67
|
+
const gz = gzipSync(Buffer.from(json, "utf8"));
|
|
68
|
+
await fs.writeFile(file, gz, { mode: 0o600 });
|
|
69
|
+
try {
|
|
70
|
+
await fs.chmod(file, 0o600);
|
|
71
|
+
} catch {
|
|
72
|
+
// Same caveat as above.
|
|
73
|
+
}
|
|
74
|
+
return file;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read the on-disk config back. Returns null when the file doesn't exist
|
|
79
|
+
* (so callers can produce a "no config for <repo>/<env>" message). Any
|
|
80
|
+
* other error — corrupt gzip, malformed JSON, validation failure — is
|
|
81
|
+
* thrown.
|
|
82
|
+
*/
|
|
83
|
+
export async function loadConfigFile(
|
|
84
|
+
repo: string,
|
|
85
|
+
env: string,
|
|
86
|
+
): Promise<ConfigFile | null> {
|
|
87
|
+
const file = configFilePath(repo, env);
|
|
88
|
+
let buf: Buffer;
|
|
89
|
+
try {
|
|
90
|
+
buf = await fs.readFile(file);
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return null;
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
const json = gunzipSync(buf).toString("utf8");
|
|
96
|
+
const parsed = JSON.parse(json);
|
|
97
|
+
validateConfigFile(parsed);
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Delete the on-disk config (no-op if missing). Doesn't touch the
|
|
103
|
+
* keychain — use keychain.deleteKey for that.
|
|
104
|
+
*/
|
|
105
|
+
export async function deleteConfigFile(
|
|
106
|
+
repo: string,
|
|
107
|
+
env: string,
|
|
108
|
+
): Promise<boolean> {
|
|
109
|
+
const file = configFilePath(repo, env);
|
|
110
|
+
try {
|
|
111
|
+
await fs.unlink(file);
|
|
112
|
+
return true;
|
|
113
|
+
} catch (err: any) {
|
|
114
|
+
if (err && err.code === "ENOENT") return false;
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Defensive shape check; mirrors validate() in envconfig.ts but key-free. */
|
|
120
|
+
export function validateConfigFile(cfg: unknown): asserts cfg is ConfigFile {
|
|
121
|
+
const c = cfg as Partial<ConfigFile> | null;
|
|
122
|
+
if (!c || typeof c !== "object") throw new Error("config: not an object");
|
|
123
|
+
if (c.version !== 1) {
|
|
124
|
+
throw new Error(`config: unsupported version ${c.version} (expected 1)`);
|
|
125
|
+
}
|
|
126
|
+
const s3 = c.s3;
|
|
127
|
+
for (const k of [
|
|
128
|
+
"endpoint",
|
|
129
|
+
"region",
|
|
130
|
+
"accessKeyId",
|
|
131
|
+
"secretAccessKey",
|
|
132
|
+
"bucket",
|
|
133
|
+
] as const) {
|
|
134
|
+
if (!s3?.[k]) throw new Error(`config: s3.${k} missing`);
|
|
135
|
+
}
|
|
136
|
+
if (typeof s3?.useSsl !== "boolean") {
|
|
137
|
+
throw new Error("config: s3.useSsl missing or not a boolean");
|
|
138
|
+
}
|
|
139
|
+
const enc = c.encryption;
|
|
140
|
+
if (!enc?.salt) throw new Error("config: encryption.salt missing");
|
|
141
|
+
if (c.files !== undefined) {
|
|
142
|
+
if (typeof c.files !== "object" || c.files === null) {
|
|
143
|
+
throw new Error("config: files must be an object if present");
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
c.files.vaultFolder !== undefined &&
|
|
147
|
+
typeof c.files.vaultFolder !== "string"
|
|
148
|
+
) {
|
|
149
|
+
throw new Error("config: files.vaultFolder must be a string if present");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (c.sync !== undefined) {
|
|
153
|
+
if (typeof c.sync !== "object" || c.sync === null) {
|
|
154
|
+
throw new Error("config: sync must be an object if present");
|
|
155
|
+
}
|
|
156
|
+
if (c.sync.gh !== undefined && (!c.sync.gh.repo || typeof c.sync.gh.repo !== "string")) {
|
|
157
|
+
throw new Error("config: sync.gh.repo must be a string if sync.gh is present");
|
|
158
|
+
}
|
|
159
|
+
if (c.sync.gcp !== undefined && (!c.sync.gcp.project || typeof c.sync.gcp.project !== "string")) {
|
|
160
|
+
throw new Error("config: sync.gcp.project must be a string if sync.gcp is present");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/s3.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Bun S3 client wrapper. Credentials come from the decoded VIDEO_AI_ENV_<NAME>
|
|
2
|
+
// blob; see src/envconfig.ts.
|
|
3
|
+
|
|
4
|
+
export type S3Credentials = {
|
|
5
|
+
endpoint: string;
|
|
6
|
+
region: string;
|
|
7
|
+
useSsl: boolean;
|
|
8
|
+
accessKeyId: string;
|
|
9
|
+
secretAccessKey: string;
|
|
10
|
+
bucket: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function makeClient(creds: S3Credentials) {
|
|
14
|
+
const protocol = creds.useSsl ? "https://" : "http://";
|
|
15
|
+
const endpoint = creds.endpoint.startsWith("http")
|
|
16
|
+
? creds.endpoint
|
|
17
|
+
: protocol + creds.endpoint;
|
|
18
|
+
return new Bun.S3Client({
|
|
19
|
+
accessKeyId: creds.accessKeyId,
|
|
20
|
+
secretAccessKey: creds.secretAccessKey,
|
|
21
|
+
region: creds.region,
|
|
22
|
+
bucket: creds.bucket,
|
|
23
|
+
endpoint,
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/sharefile.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// sharefile.ts — build / parse the passphrase-encrypted share file that
|
|
2
|
+
// teammates pass between machines.
|
|
3
|
+
//
|
|
4
|
+
// Wire format (file bytes, written verbatim — no base64 wrapper):
|
|
5
|
+
// bytes 0..3 "SLS1" — magic for the share file itself
|
|
6
|
+
// bytes 4 saltLen (1 byte) — length of the salt that follows
|
|
7
|
+
// bytes 5..5+L salt (L bytes) — base64-string bytes, used by PBKDF2
|
|
8
|
+
// bytes rest — output of src/crypto.ts encrypt()
|
|
9
|
+
// (which has its own RQE1 magic + IV + ct)
|
|
10
|
+
//
|
|
11
|
+
// The encrypted payload is the ExportPayload JSON (config + key + repo + env).
|
|
12
|
+
|
|
13
|
+
import { encrypt, decrypt } from "./crypto";
|
|
14
|
+
import {
|
|
15
|
+
EXPORT_BLOB_VERSION,
|
|
16
|
+
type ExportPayload,
|
|
17
|
+
parseExportBlob,
|
|
18
|
+
buildExportBlob,
|
|
19
|
+
} from "./envconfig";
|
|
20
|
+
|
|
21
|
+
const SHARE_MAGIC = new Uint8Array([0x53, 0x4c, 0x53, 0x31]); // "SLS1"
|
|
22
|
+
const SALT_BYTES = 16;
|
|
23
|
+
|
|
24
|
+
/** Encrypt + frame the payload into a single byte sequence suitable for
|
|
25
|
+
* writing to a `.share` file. */
|
|
26
|
+
export async function buildShareFile(
|
|
27
|
+
payload: ExportPayload,
|
|
28
|
+
passphrase: string,
|
|
29
|
+
): Promise<Uint8Array> {
|
|
30
|
+
if (payload.version !== EXPORT_BLOB_VERSION) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`buildShareFile: payload.version ${payload.version} not supported`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const saltBytes = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
36
|
+
const saltStr = Buffer.from(saltBytes).toString("base64");
|
|
37
|
+
|
|
38
|
+
// Re-use envconfig.buildExportBlob so the JSON+gzip+base64 framing
|
|
39
|
+
// matches what was the env-var-era export. The result is one base64
|
|
40
|
+
// ASCII string; we encrypt those bytes.
|
|
41
|
+
const blobStr = buildExportBlob(payload);
|
|
42
|
+
const blobBytes = new TextEncoder().encode(blobStr);
|
|
43
|
+
const encrypted = await encrypt(blobBytes, passphrase, saltStr);
|
|
44
|
+
|
|
45
|
+
const saltAscii = new TextEncoder().encode(saltStr);
|
|
46
|
+
if (saltAscii.length > 0xff) {
|
|
47
|
+
throw new Error("internal: salt too long for 1-byte length prefix");
|
|
48
|
+
}
|
|
49
|
+
const out = new Uint8Array(
|
|
50
|
+
SHARE_MAGIC.length + 1 + saltAscii.length + encrypted.length,
|
|
51
|
+
);
|
|
52
|
+
let offset = 0;
|
|
53
|
+
out.set(SHARE_MAGIC, offset);
|
|
54
|
+
offset += SHARE_MAGIC.length;
|
|
55
|
+
out[offset] = saltAscii.length;
|
|
56
|
+
offset += 1;
|
|
57
|
+
out.set(saltAscii, offset);
|
|
58
|
+
offset += saltAscii.length;
|
|
59
|
+
out.set(encrypted, offset);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Decrypt + unframe — the inverse of buildShareFile. */
|
|
64
|
+
export async function parseShareFile(
|
|
65
|
+
bytes: Uint8Array,
|
|
66
|
+
passphrase: string,
|
|
67
|
+
): Promise<ExportPayload> {
|
|
68
|
+
if (bytes.length < SHARE_MAGIC.length + 1) {
|
|
69
|
+
throw new Error("share file is too short");
|
|
70
|
+
}
|
|
71
|
+
for (let i = 0; i < SHARE_MAGIC.length; i++) {
|
|
72
|
+
if (bytes[i] !== SHARE_MAGIC[i]) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"not a vsync share file (magic header missing). " +
|
|
75
|
+
"Check that you passed the file produced by `vsync export`.",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let offset = SHARE_MAGIC.length;
|
|
80
|
+
const saltLen = bytes[offset]!;
|
|
81
|
+
offset += 1;
|
|
82
|
+
if (bytes.length < offset + saltLen) {
|
|
83
|
+
throw new Error("share file truncated (salt header)");
|
|
84
|
+
}
|
|
85
|
+
const saltStr = new TextDecoder().decode(bytes.subarray(offset, offset + saltLen));
|
|
86
|
+
offset += saltLen;
|
|
87
|
+
const ciphertext = bytes.subarray(offset);
|
|
88
|
+
|
|
89
|
+
let decrypted: Uint8Array;
|
|
90
|
+
try {
|
|
91
|
+
decrypted = await decrypt(ciphertext, passphrase, saltStr);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"failed to decrypt share file — passphrase wrong or file corrupt. " +
|
|
95
|
+
"Ask the sender to re-share both.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const blobStr = new TextDecoder().decode(decrypted);
|
|
99
|
+
return parseExportBlob(blobStr);
|
|
100
|
+
}
|
package/src/syncpool.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Bounded worker pool for fan-out secret pushes.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors reqsume/secrets.go:runTasksConcurrently — N workers pull tasks off
|
|
4
|
+
// a shared cursor, failures are collected (don't abort), an overall timeout
|
|
5
|
+
// aborts everything via AbortSignal.
|
|
6
|
+
|
|
7
|
+
import type { SecretTask } from "./envfile";
|
|
8
|
+
|
|
9
|
+
export type SyncResult = { failed: string[]; ok: number };
|
|
10
|
+
|
|
11
|
+
export async function runPool(
|
|
12
|
+
tasks: SecretTask[],
|
|
13
|
+
workers: number,
|
|
14
|
+
timeoutMs: number,
|
|
15
|
+
fn: (t: SecretTask, signal: AbortSignal) => Promise<void>,
|
|
16
|
+
): Promise<SyncResult> {
|
|
17
|
+
const n = Math.max(1, workers);
|
|
18
|
+
const ctrl = new AbortController();
|
|
19
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
20
|
+
|
|
21
|
+
const failed: string[] = [];
|
|
22
|
+
let ok = 0;
|
|
23
|
+
let cursor = 0;
|
|
24
|
+
|
|
25
|
+
const worker = async () => {
|
|
26
|
+
while (!ctrl.signal.aborted) {
|
|
27
|
+
const idx = cursor++;
|
|
28
|
+
if (idx >= tasks.length) return;
|
|
29
|
+
const t = tasks[idx];
|
|
30
|
+
try {
|
|
31
|
+
await fn(t, ctrl.signal);
|
|
32
|
+
ok++;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error(`WARNING: skipping ${t.key}: ${(e as Error).message}`);
|
|
35
|
+
failed.push(t.key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await Promise.all(Array.from({ length: n }, () => worker()));
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { ok, failed };
|
|
47
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Static onboarding reference emitted by `vsync docs`.
|
|
2
|
+
//
|
|
3
|
+
// Lives as a string export so it ships with the binary and stays in
|
|
4
|
+
// sync with the verb set. Pipe to a file if you want to commit it:
|
|
5
|
+
// vsync docs > infra/AGENTS.md
|
|
6
|
+
|
|
7
|
+
export const DOCS_MD = `# vsync — onboarding reference
|
|
8
|
+
|
|
9
|
+
Generated by \`vsync docs\` and intended to be committed at the path of
|
|
10
|
+
your choice (\`infra/AGENTS.md\`, \`infra/CLAUDE.md\`, etc.) so AI agents
|
|
11
|
+
and teammates landing in this repo know where secrets live and how to
|
|
12
|
+
work with them.
|
|
13
|
+
|
|
14
|
+
## What this repo uses
|
|
15
|
+
|
|
16
|
+
Secrets sync via \`@muthuishere/vsync\`. The canonical store is an
|
|
17
|
+
S3-compatible bucket; per-machine state lives at
|
|
18
|
+
\`~/.config/vsync/\` plus an OS keychain entry under service
|
|
19
|
+
\`tools.vsync\`.
|
|
20
|
+
|
|
21
|
+
## Where secret content lives
|
|
22
|
+
|
|
23
|
+
\`\`\`
|
|
24
|
+
infra/vault/
|
|
25
|
+
dev/
|
|
26
|
+
.env.dev
|
|
27
|
+
some-secret.json
|
|
28
|
+
...
|
|
29
|
+
production/
|
|
30
|
+
.env.production
|
|
31
|
+
\`\`\`
|
|
32
|
+
|
|
33
|
+
(\`infra/vault/<env>\` is the default — a per-(repo, env) override may
|
|
34
|
+
point elsewhere; check \`~/.config/vsync/<repo>/env_<env>\` for
|
|
35
|
+
\`files.vaultFolder\`.)
|
|
36
|
+
|
|
37
|
+
Apps point dotenv (or equivalent) at the in-vault path:
|
|
38
|
+
|
|
39
|
+
\`\`\`js
|
|
40
|
+
dotenv.config({ path: \`infra/vault/\${env}/.env.\${env}\` });
|
|
41
|
+
\`\`\`
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
| Command | Purpose |
|
|
46
|
+
|---|---|
|
|
47
|
+
| \`vsync init <env>\` | First-time setup: generate AES key, write per-repo config, create vault folder. Re-runnable. |
|
|
48
|
+
| \`vsync export <env>\` | Write a passphrase-encrypted \`.share\` file to send a teammate (file + passphrase on different channels). |
|
|
49
|
+
| \`vsync import <env> <file>\` | Install a teammate's \`.share\` file. No prior init required. |
|
|
50
|
+
| \`vsync push <env>\` | Encrypt the vault folder and upload to S3. |
|
|
51
|
+
| \`vsync pull <env>\` | Download the latest from S3 and unzip into the vault folder (auto-backs up the existing folder first). |
|
|
52
|
+
| \`vsync versions <env>\` | List the available versions on S3 (read-only, no decrypt). |
|
|
53
|
+
| \`vsync sync <env> <gh\\|gcp\\|all>\` | Push the vault's \`.env.<env>\` KVs out to GitHub Repo Secrets / GCP Secret Manager. |
|
|
54
|
+
| \`vsync docs\` | Print this reference. |
|
|
55
|
+
|
|
56
|
+
Every command takes \`--repo=<name>\` to override the auto-detected repo
|
|
57
|
+
name and \`--interactive\` to force prompts. Each command works fully
|
|
58
|
+
via flags or fully via prompts.
|
|
59
|
+
|
|
60
|
+
## Recovering a local backup
|
|
61
|
+
|
|
62
|
+
Each \`vsync pull\` writes the prior vault folder to
|
|
63
|
+
\`~/.config/vsync/backups/<env>-<ts>.zip.enc\` (two-deep rolling buffer).
|
|
64
|
+
The format is AES-256-GCM with the same per-(repo, env) keychain key
|
|
65
|
+
plus the salt from the per-repo config. To decrypt by hand:
|
|
66
|
+
|
|
67
|
+
1. Get the keychain key:
|
|
68
|
+
- macOS: \`security find-generic-password -s tools.vsync -a <repo>/<env> -w\`
|
|
69
|
+
- Linux: \`secret-tool lookup service tools.vsync account <repo>/<env>\`
|
|
70
|
+
2. Get the salt: \`gunzip -c ~/.config/vsync/<repo>/env_<env> | jq -r .encryption.salt\`
|
|
71
|
+
3. The envelope is \`RQE1\` (4-byte magic) + 12-byte IV + AES-GCM ciphertext.
|
|
72
|
+
Derive \`AES-256-GCM key = PBKDF2-SHA256(keychain-key, salt, 600k)\`.
|
|
73
|
+
|
|
74
|
+
Easier: just \`vsync pull\` again to restore from S3.
|
|
75
|
+
|
|
76
|
+
## Rules for AI agents working in this repo
|
|
77
|
+
|
|
78
|
+
1. **Never commit anything under \`infra/vault/\`.** Add it to
|
|
79
|
+
\`.gitignore\` if missing.
|
|
80
|
+
2. **Never read or edit files under \`~/.config/vsync/\` directly.**
|
|
81
|
+
Use the CLI verbs.
|
|
82
|
+
3. **Never paste keychain keys or salts into source code, comments,
|
|
83
|
+
PR descriptions, or chat.** They're machine-local secrets.
|
|
84
|
+
4. **Routing config (sync.gh.repo, sync.gcp.project) is per-(repo, env).**
|
|
85
|
+
Read it from \`~/.config/vsync/<repo>/env_<env>\` if you need to know
|
|
86
|
+
the GH repo or GCP project the team syncs to. Don't hardcode it.
|
|
87
|
+
5. **If a command asks for a passphrase or share-file path, surface the
|
|
88
|
+
prompt to the human user.** Don't auto-fill or skip.
|
|
89
|
+
`;
|