@imagina-build/ias-cli 0.0.0 → 0.1.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 +406 -2
- package/bin/ias.mjs +19 -0
- package/package.json +31 -12
- package/schema/ias.schema.json +535 -0
- package/shared/credentials/cms.mjs +105 -0
- package/shared/credentials/index.mjs +354 -0
- package/shared/credentials/plist.mjs +93 -0
- package/shared/vault/approvers-vectors.mjs +188 -0
- package/shared/vault/approvers.mjs +234 -0
- package/shared/vault/index.mjs +583 -0
- package/shared/vault/plaintext.mjs +115 -0
- package/shared/vault/test-vectors/ias-vault-2/approvers.json +2122 -0
- package/shared/vault/test-vectors/ias-vault-2/keys.json +43 -0
- package/shared/vault/test-vectors/ias-vault-2/malformed.json +1739 -0
- package/shared/vault/test-vectors/ias-vault-2/plaintexts.json +149 -0
- package/shared/vault/test-vectors/ias-vault-2/runners.json +580 -0
- package/shared/vault/test-vectors/ias-vault-2/sealed.json +567 -0
- package/shared/vault/test-vectors/ias-vault-2/tampered.json +989 -0
- package/shared/vault/test-vectors/keys.json +31 -0
- package/shared/vault/test-vectors/malformed.json +1341 -0
- package/shared/vault/test-vectors/sealed.json +384 -0
- package/shared/vault/test-vectors/tampered.json +610 -0
- package/shared/vault/vectors.mjs +720 -0
- package/src/cli.mjs +231 -0
- package/src/commands/apps/index.mjs +36 -0
- package/src/commands/auth/index.mjs +141 -0
- package/src/commands/build/follow.mjs +168 -0
- package/src/commands/build/index.mjs +110 -0
- package/src/commands/build/local.mjs +69 -0
- package/src/commands/build/profile.mjs +50 -0
- package/src/commands/build/source.mjs +71 -0
- package/src/commands/builds/index.mjs +148 -0
- package/src/commands/credentials/index.mjs +256 -0
- package/src/commands/credentials/material.mjs +159 -0
- package/src/commands/devices/asc.mjs +116 -0
- package/src/commands/devices/index.mjs +65 -0
- package/src/commands/devices/sync.mjs +223 -0
- package/src/commands/dist/index.mjs +108 -0
- package/src/commands/doctor/index.mjs +158 -0
- package/src/commands/env/index.mjs +163 -0
- package/src/commands/github/index.mjs +53 -0
- package/src/commands/init/index.mjs +224 -0
- package/src/commands/migrate/credentials.mjs +213 -0
- package/src/commands/migrate/eas-cli.mjs +153 -0
- package/src/commands/migrate/index.mjs +73 -0
- package/src/commands/migrate/numbers.mjs +85 -0
- package/src/commands/migrate/profiles.mjs +174 -0
- package/src/commands/migrate/project.mjs +249 -0
- package/src/commands/migrate/survey.mjs +205 -0
- package/src/commands/migrate/variables.mjs +183 -0
- package/src/commands/push/index.mjs +334 -0
- package/src/commands/sdk/index.mjs +81 -0
- package/src/commands/submit/index.mjs +85 -0
- package/src/commands/update/index.mjs +526 -0
- package/src/commands/usage/index.mjs +57 -0
- package/src/commands/vault/approvers.mjs +129 -0
- package/src/commands/vault/index.mjs +159 -0
- package/src/commands/vault/runners.mjs +115 -0
- package/src/commands/vault/team.mjs +111 -0
- package/src/commands/version/index.mjs +71 -0
- package/src/commands/webhooks/index.mjs +104 -0
- package/src/commands/workflows/index.mjs +88 -0
- package/src/config.mjs +261 -0
- package/src/dist.mjs +99 -0
- package/src/errors.mjs +158 -0
- package/src/http.mjs +45 -0
- package/src/legacy.mjs +73 -0
- package/src/manage.mjs +120 -0
- package/src/ota.mjs +122 -0
- package/src/output.mjs +36 -0
- package/src/process.mjs +45 -0
- package/src/schema.mjs +131 -0
- package/src/sdk.mjs +120 -0
- package/src/secret-input.mjs +27 -0
- package/src/secrets.mjs +37 -0
- package/src/shared.mjs +5 -0
- package/src/team-vault.mjs +152 -0
- package/src/token-store.mjs +93 -0
package/src/schema.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ias.json and its JSON Schema (schema/ias.schema.json), which the runner and the SDK read too. The validator covers
|
|
2
|
+
// the part of JSON Schema 2020-12 that the schema uses; a keyword it does not know is an error, so the schema cannot
|
|
3
|
+
// grow past what is checked.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export const SCHEMA_URL = new URL('../schema/ias.schema.json', import.meta.url);
|
|
7
|
+
|
|
8
|
+
let schema;
|
|
9
|
+
|
|
10
|
+
/** The JSON Schema of ias.json. */
|
|
11
|
+
export function iasSchema() {
|
|
12
|
+
schema ??= JSON.parse(readFileSync(SCHEMA_URL, 'utf8'));
|
|
13
|
+
return schema;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Keywords that only describe: they never fail. */
|
|
17
|
+
const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'markdownDescription', 'deprecated', 'format']);
|
|
18
|
+
|
|
19
|
+
const typeOf = (value) => (value === null ? 'null' : Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value);
|
|
20
|
+
const matchesType = (value, type) => typeOf(value) === type || (type === 'number' && typeOf(value) === 'integer');
|
|
21
|
+
const show = (value) => JSON.stringify(value);
|
|
22
|
+
const at = (path) => (path.length > 0 ? path.join('.') : 'the file');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The errors of `value` against `schema` (the schema of ias.json by default), as sentences that start with the path:
|
|
26
|
+
* «build.preview.android.format must be one of "apk", "aab"». An empty list means it is valid.
|
|
27
|
+
*/
|
|
28
|
+
export function validate(value, rootSchema = iasSchema()) {
|
|
29
|
+
const errors = [];
|
|
30
|
+
check(value, rootSchema, [], rootSchema, errors);
|
|
31
|
+
return errors;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveRef(ref, root) {
|
|
35
|
+
if (!ref.startsWith('#/')) throw new Error(`the schema refers to ${ref}: only #/… references are supported`);
|
|
36
|
+
const node = ref.slice(2).split('/').reduce((parent, key) => parent?.[key.replaceAll('~1', '/').replaceAll('~0', '~')], root);
|
|
37
|
+
if (node === undefined) throw new Error(`the schema has no ${ref}`);
|
|
38
|
+
return node;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function check(value, node, path, root, errors) {
|
|
42
|
+
if (node === true) return;
|
|
43
|
+
if (node === false) {
|
|
44
|
+
errors.push(`${at(path)} is not allowed`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const [keyword, rule] of Object.entries(node)) {
|
|
48
|
+
if (ANNOTATIONS.has(keyword) || keyword === '$defs') continue;
|
|
49
|
+
switch (keyword) {
|
|
50
|
+
case '$ref':
|
|
51
|
+
check(value, resolveRef(rule, root), path, root, errors);
|
|
52
|
+
break;
|
|
53
|
+
case 'type': {
|
|
54
|
+
const types = [rule].flat();
|
|
55
|
+
if (!types.some((type) => matchesType(value, type))) {
|
|
56
|
+
errors.push(`${at(path)} must be ${types.map((type) => (/^[aeiou]/.test(type) ? `an ${type}` : `a ${type}`)).join(' or ')}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case 'enum':
|
|
62
|
+
if (!rule.some((option) => JSON.stringify(option) === JSON.stringify(value))) errors.push(`${at(path)} must be one of ${rule.map(show).join(', ')}`);
|
|
63
|
+
break;
|
|
64
|
+
case 'const':
|
|
65
|
+
if (JSON.stringify(rule) !== JSON.stringify(value)) errors.push(`${at(path)} must be ${show(rule)}`);
|
|
66
|
+
break;
|
|
67
|
+
case 'pattern':
|
|
68
|
+
if (typeof value === 'string' && !new RegExp(rule, 'u').test(value)) errors.push(`${at(path)} (${show(value)}) must match ${rule}`);
|
|
69
|
+
break;
|
|
70
|
+
case 'minLength':
|
|
71
|
+
if (typeof value === 'string' && [...value].length < rule) errors.push(`${at(path)} must have at least ${rule} characters`);
|
|
72
|
+
break;
|
|
73
|
+
case 'maxLength':
|
|
74
|
+
if (typeof value === 'string' && [...value].length > rule) errors.push(`${at(path)} must have at most ${rule} characters`);
|
|
75
|
+
break;
|
|
76
|
+
case 'minimum':
|
|
77
|
+
if (typeof value === 'number' && value < rule) errors.push(`${at(path)} must be at least ${rule}`);
|
|
78
|
+
break;
|
|
79
|
+
case 'maximum':
|
|
80
|
+
if (typeof value === 'number' && value > rule) errors.push(`${at(path)} must be at most ${rule}`);
|
|
81
|
+
break;
|
|
82
|
+
case 'required':
|
|
83
|
+
if (typeOf(value) === 'object') for (const key of rule) if (!(key in value)) errors.push(`${at([...path, key])} is missing`);
|
|
84
|
+
break;
|
|
85
|
+
case 'properties':
|
|
86
|
+
if (typeOf(value) === 'object') for (const [key, child] of Object.entries(rule)) if (key in value) check(value[key], child, [...path, key], root, errors);
|
|
87
|
+
break;
|
|
88
|
+
case 'propertyNames':
|
|
89
|
+
if (typeOf(value) === 'object') {
|
|
90
|
+
for (const key of Object.keys(value)) {
|
|
91
|
+
const nameErrors = [];
|
|
92
|
+
check(key, rule, [...path, key], root, nameErrors);
|
|
93
|
+
if (nameErrors.length > 0) errors.push(`${at(path)} has a key that is not valid (${show(key)})`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
case 'additionalProperties':
|
|
98
|
+
if (typeOf(value) === 'object') {
|
|
99
|
+
const known = Object.keys(node.properties ?? {});
|
|
100
|
+
for (const key of Object.keys(value).filter((candidate) => !known.includes(candidate))) {
|
|
101
|
+
if (rule === false) errors.push(`${at(path)} has an unknown key ${show(key)}`);
|
|
102
|
+
else check(value[key], rule, [...path, key], root, errors);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
case 'items':
|
|
107
|
+
if (Array.isArray(value)) value.forEach((item, index) => check(item, rule, [...path, String(index)], root, errors));
|
|
108
|
+
break;
|
|
109
|
+
case 'minItems':
|
|
110
|
+
if (Array.isArray(value) && value.length < rule) errors.push(`${at(path)} must have at least ${rule} items`);
|
|
111
|
+
break;
|
|
112
|
+
case 'maxItems':
|
|
113
|
+
if (Array.isArray(value) && value.length > rule) errors.push(`${at(path)} must have at most ${rule} items`);
|
|
114
|
+
break;
|
|
115
|
+
case 'minProperties':
|
|
116
|
+
if (typeOf(value) === 'object' && Object.keys(value).length < rule) errors.push(`${at(path)} must have at least ${rule} fields`);
|
|
117
|
+
break;
|
|
118
|
+
case 'uniqueItems':
|
|
119
|
+
if (rule && Array.isArray(value) && new Set(value.map((item) => JSON.stringify(item))).size !== value.length) errors.push(`${at(path)} must not repeat items`);
|
|
120
|
+
break;
|
|
121
|
+
case 'anyOf':
|
|
122
|
+
case 'oneOf': {
|
|
123
|
+
const matching = rule.filter((option) => validate(value, { ...option, $defs: root.$defs }).length === 0).length;
|
|
124
|
+
if (matching === 0 || (keyword === 'oneOf' && matching > 1)) errors.push(`${at(path)} (${show(value)}) does not match any of the allowed forms`);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`the schema uses ${keyword}, which this validator does not check`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/sdk.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The SDK @imagina/ias (packages/sdk) in an app: which package manager installs it, where it comes from, whether it is
|
|
2
|
+
// installed and compatible with this ias, and its config plugin in the plugins of app.json. ias sdk install and ias
|
|
3
|
+
// doctor share it.
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { VERSION } from './cli.mjs';
|
|
8
|
+
import { DYNAMIC_CONFIGS, IAS_PLUGINS, readExpo, readJson } from './config.mjs';
|
|
9
|
+
import { IasError } from './errors.mjs';
|
|
10
|
+
import { INSTALLS } from './ota.mjs';
|
|
11
|
+
|
|
12
|
+
export const SDK_PACKAGE = '@imagina/ias';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The registry the packages are published to (scripts/publish.mjs) and their scope there: the public npm registry,
|
|
16
|
+
* where the organization is imagina-build, so @imagina/ias goes out as @imagina-build/ias and the apps install it under
|
|
17
|
+
* its own name with an alias (@imagina/ias@npm:@imagina-build/ias@…). Installing needs no token.
|
|
18
|
+
*/
|
|
19
|
+
export const REGISTRY = 'https://registry.npmjs.org';
|
|
20
|
+
export const REGISTRY_SCOPE = 'imagina-build';
|
|
21
|
+
export const REGISTRY_SDK = `@${REGISTRY_SCOPE}/ias`;
|
|
22
|
+
|
|
23
|
+
/** How each package manager adds a dependency. */
|
|
24
|
+
const ADD = { npm: ['install'], yarn: ['add'], pnpm: ['add'], bun: ['add'] };
|
|
25
|
+
|
|
26
|
+
/** The SDK next to this CLI when it runs from a clone of the repository (packages/sdk), or undefined. */
|
|
27
|
+
export function repositorySdk() {
|
|
28
|
+
const dir = fileURLToPath(new URL('../../sdk/', import.meta.url));
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name === SDK_PACKAGE ? resolve(dir) : undefined;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The range ias sdk install asks for: ^VERSION, the same minor while the major is 0. */
|
|
37
|
+
export const compatibleRange = (version = VERSION) => `^${version}`;
|
|
38
|
+
|
|
39
|
+
/** Whether `version` of the SDK speaks the same ias.json as `cliVersion`: the same major, and the same minor while it is 0. */
|
|
40
|
+
export function isCompatible(version, cliVersion = VERSION) {
|
|
41
|
+
const [major, minor] = String(version).split('.').map(Number);
|
|
42
|
+
const [cliMajor, cliMinor] = String(cliVersion).split('.').map(Number);
|
|
43
|
+
return major === cliMajor && (major !== 0 || minor === cliMinor);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where the SDK is installed from: --from, IAS_SDK_SOURCE, the packages/sdk of the repository when ias runs from a
|
|
48
|
+
* clone, or npm (REGISTRY_SDK@^VERSION under the name @imagina/ias). { spec, origin }; a folder of the
|
|
49
|
+
* repository becomes a path relative to the app, as package.json keeps it.
|
|
50
|
+
*/
|
|
51
|
+
export function sdkSource(dir, { from, env = {}, repository = repositorySdk() } = {}) {
|
|
52
|
+
const asSpec = (value) => (!/^[a-z+]+:/i.test(value) && existsSync(resolve(dir, value)) ? `file:${relative(dir, resolve(dir, value)) || '.'}` : value);
|
|
53
|
+
if (from) return { spec: asSpec(from), origin: '--from' };
|
|
54
|
+
if (env.IAS_SDK_SOURCE) return { spec: asSpec(env.IAS_SDK_SOURCE), origin: 'IAS_SDK_SOURCE' };
|
|
55
|
+
if (repository) return { spec: `file:${relative(dir, repository)}`, origin: 'the packages/sdk of the repository ias runs from', folder: repository };
|
|
56
|
+
return { spec: `${SDK_PACKAGE}@npm:${REGISTRY_SDK}@${compatibleRange()}`, origin: `npm (${REGISTRY_SDK})` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The package manager of the app: packageManager of package.json, or the first lockfile from `dir` up; npm without one. */
|
|
60
|
+
export function packageManager(dir) {
|
|
61
|
+
const declared = readPackageJson(dir)?.packageManager;
|
|
62
|
+
const named = typeof declared === 'string' ? declared.split('@')[0] : undefined;
|
|
63
|
+
if (ADD[named]) return { name: named, from: 'packageManager of package.json' };
|
|
64
|
+
for (let current = resolve(dir); ; current = dirname(current)) {
|
|
65
|
+
const install = INSTALLS.find(([lockfile]) => existsSync(join(current, lockfile)));
|
|
66
|
+
if (install) return { name: install[1], from: relative(dir, join(current, install[0])) || install[0] };
|
|
67
|
+
if (dirname(current) === current) return { name: 'npm', from: 'no lockfile' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The command that adds `spec` with `manager`: [program, args]. */
|
|
72
|
+
export const addCommand = (manager, spec) => [manager, [...ADD[manager], spec]];
|
|
73
|
+
|
|
74
|
+
function readPackageJson(dir) {
|
|
75
|
+
const file = join(dir, 'package.json');
|
|
76
|
+
return existsSync(file) ? readJson(file, 'package.json', 'fix package.json: ias reads the dependencies of the app from it') : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The SDK in the app in `dir`: { packageJson (whether there is one), declared (its range in the dependencies),
|
|
81
|
+
* installed (the version in node_modules), plugin (yes, no, or dynamic when an app.config.* may add it), dynamic }.
|
|
82
|
+
*/
|
|
83
|
+
export function sdkState(dir) {
|
|
84
|
+
const pkg = readPackageJson(dir);
|
|
85
|
+
const declared = pkg?.dependencies?.[SDK_PACKAGE] ?? pkg?.devDependencies?.[SDK_PACKAGE];
|
|
86
|
+
let installed;
|
|
87
|
+
for (let current = resolve(dir); ; current = dirname(current)) {
|
|
88
|
+
const file = join(current, 'node_modules', SDK_PACKAGE, 'package.json');
|
|
89
|
+
if (existsSync(file)) {
|
|
90
|
+
installed = readJson(file, `node_modules/${SDK_PACKAGE}/package.json`, 'install the SDK again (ias sdk install)').version;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (dirname(current) === current) break;
|
|
94
|
+
}
|
|
95
|
+
const dynamic = DYNAMIC_CONFIGS.find((name) => existsSync(join(dir, name)));
|
|
96
|
+
const listed = hasIasPlugin(readExpo(dir)?.plugins);
|
|
97
|
+
return { packageJson: Boolean(pkg), declared, installed, plugin: listed ? 'yes' : dynamic ? 'dynamic' : 'no', dynamic, dependencies: { ...pkg?.devDependencies, ...pkg?.dependencies } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hasIasPlugin = (plugins) => (Array.isArray(plugins) ? plugins : []).some((entry) => IAS_PLUGINS.includes(Array.isArray(entry) ? entry[0] : entry));
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Adds "@imagina/ias" to the plugins of app.json, keeping the rest of the file and its indentation: { changed, reason }.
|
|
104
|
+
* With an app.config.*, which ias cannot rewrite, it changes nothing and says so (reason dynamic).
|
|
105
|
+
*/
|
|
106
|
+
export function addPlugin(dir, { dryRun = false } = {}) {
|
|
107
|
+
const dynamic = DYNAMIC_CONFIGS.find((name) => existsSync(join(dir, name)));
|
|
108
|
+
if (dynamic) return { changed: false, reason: 'dynamic', file: dynamic };
|
|
109
|
+
const file = join(dir, 'app.json');
|
|
110
|
+
if (!existsSync(file)) throw new IasError(`no app.json in ${dir}`, { fix: 'run it in the folder of the Expo app, or pass --project <app folder>' });
|
|
111
|
+
const text = readFileSync(file, 'utf8');
|
|
112
|
+
const appJson = readJson(file, 'app.json', 'fix app.json, then run ias sdk install again');
|
|
113
|
+
if (!appJson?.expo || typeof appJson.expo !== 'object') throw new IasError('app.json has no expo object', { fix: 'the config plugin goes in app.json › expo › plugins' });
|
|
114
|
+
if (appJson.expo.plugins !== undefined && !Array.isArray(appJson.expo.plugins)) throw new IasError('plugins of app.json is not a list', { fix: 'make it a list, such as "plugins": ["@imagina/ias"]' });
|
|
115
|
+
if (hasIasPlugin(appJson.expo.plugins)) return { changed: false, reason: 'present', file: 'app.json' };
|
|
116
|
+
appJson.expo.plugins = [...(appJson.expo.plugins ?? []), SDK_PACKAGE];
|
|
117
|
+
const indent = text.match(/^[ \t]+(?=")/m)?.[0] ?? ' ';
|
|
118
|
+
if (!dryRun) writeFileSync(file, `${JSON.stringify(appJson, null, indent)}\n`);
|
|
119
|
+
return { changed: true, reason: 'added', file: 'app.json' };
|
|
120
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Where a command reads a secret it was given: an op:// reference, an environment variable (env:NAME) or stdin (-).
|
|
2
|
+
// Never the secret itself on the command line, where ps and the history of the shell would keep it.
|
|
3
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { IasError } from './errors.mjs';
|
|
6
|
+
import { isReference, readSecret, readSecretFile } from './secrets.mjs';
|
|
7
|
+
|
|
8
|
+
/** The value of a secret given as op://…, env:NAME or - (stdin, without its last newline). */
|
|
9
|
+
export function secretValue(spec, { env, option, stdin = () => readFileSync(0, 'utf8') }) {
|
|
10
|
+
if (typeof spec !== 'string' || spec === '') throw new IasError(`${option} is missing`, { fix: `give it as an op:// reference, env:NAME (a variable of the environment) or - (stdin)` });
|
|
11
|
+
if (isReference(spec)) return readSecret(spec, { env });
|
|
12
|
+
if (spec.startsWith('env:')) {
|
|
13
|
+
const name = spec.slice(4);
|
|
14
|
+
if (env[name] === undefined) throw new IasError(`${option} is env:${name}, but ${name} is not set`, { fix: `export ${name} first, or give an op:// reference` });
|
|
15
|
+
return env[name];
|
|
16
|
+
}
|
|
17
|
+
if (spec === '-') return stdin().replace(/\r?\n$/, '');
|
|
18
|
+
throw new IasError(`${option} takes a reference, not the secret itself`, { fix: 'a secret on the command line is seen by ps and kept in the shell history: give op://…, env:NAME or - (stdin)', code: 'secret_on_command_line' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A file given as a path or an op:// reference (read with op into this run's temporary folder): its path. */
|
|
22
|
+
export function filePath(spec, { env, cwd, name }) {
|
|
23
|
+
if (isReference(spec)) return readSecretFile(spec, name, { env });
|
|
24
|
+
const path = resolve(cwd, spec);
|
|
25
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new IasError(`there is no file at ${spec}`, { fix: 'give a path or an op:// reference to the file in 1Password' });
|
|
26
|
+
return path;
|
|
27
|
+
}
|
package/src/secrets.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Secrets: never in a file of the repository. They are read from 1Password (op:// references) or from the environment.
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { temporary, run } from './process.mjs';
|
|
4
|
+
|
|
5
|
+
export const isReference = (value) => typeof value === 'string' && value.startsWith('op://');
|
|
6
|
+
|
|
7
|
+
/** The value of an op:// reference, with the 1Password CLI (Touch ID with the desktop app), or `value` as it is. */
|
|
8
|
+
export const readSecret = (value, { stdio = ['inherit', 'pipe', 'inherit'], env } = {}) => (isReference(value) ? run('op', ['read', '--no-newline', value], { label: `op read ${value}`, stdio, ...(env ? { env } : {}) }) : value);
|
|
9
|
+
|
|
10
|
+
/** An op:// reference written to a file of this run's temporary folder, readable only by this user; returns its path. */
|
|
11
|
+
export function readSecretFile(reference, name, { env } = {}) {
|
|
12
|
+
const path = join(temporary(), name);
|
|
13
|
+
run('op', ['read', '--file-mode', '0600', '--out-file', path, reference], { label: `op read ${reference}`, stdio: ['inherit', 'ignore', 'inherit'], ...(env ? { env } : {}) });
|
|
14
|
+
return path;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What a secret written in a file looks like: a token of IAS (ota_, dist_, iasp_, iasr_) or a PEM block. */
|
|
18
|
+
const SECRET = /^(?:(?:ota|dist|iasp|iasr)_[A-Za-z0-9_-]{16,}|-----BEGIN [A-Z ]+-----)/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The paths («updates.token») of the secrets written in a configuration: any `token` or `privateKey` that is not an
|
|
22
|
+
* op:// reference, and any value that looks like a token or a key.
|
|
23
|
+
*/
|
|
24
|
+
export function writtenSecrets(value, path = []) {
|
|
25
|
+
if (typeof value === 'string') {
|
|
26
|
+
const key = path.at(-1);
|
|
27
|
+
return (['token', 'privateKey'].includes(key) && !isReference(value)) || SECRET.test(value.trim()) ? [path.join('.')] : [];
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value)) return value.flatMap((item, index) => writtenSecrets(item, [...path, String(index)]));
|
|
30
|
+
if (value && typeof value === 'object') return Object.entries(value).flatMap(([key, item]) => writtenSecrets(item, [...path, key]));
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The kind of a token by its prefix: ota_ and dist_ are today's robot tokens; iasp_ and iasr_ come with track 1A. */
|
|
35
|
+
export function tokenKind(token) {
|
|
36
|
+
return token.match(/^(ota|dist|iasp|iasr)_/)?.[1];
|
|
37
|
+
}
|
package/src/shared.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// What the CLI takes from @imagina/ias-shared (packages/shared): the vault (docs/technical/vault.md) and the reading of the
|
|
2
|
+
// signing credentials. The packages have no dependencies (decision D5), so it is a path inside the repository; npm pack
|
|
3
|
+
// does not take ../shared with it, so scripts/pack.mjs packs a copy with shared/ inside and points these imports there.
|
|
4
|
+
export * from '../shared/vault/index.mjs';
|
|
5
|
+
export * as credentials from '../shared/credentials/index.mjs';
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The team's keys of the vault (docs/technical/vault.md §2): the recovery key (X25519) and the signing key (Ed25519), in one
|
|
2
|
+
// 1Password item that ias vault init creates. The CLI trusts that item, never the server: before sealing it compares
|
|
3
|
+
// the public keys of the item with the ones of GET /vault, and it seals only for the runners of the list the team
|
|
4
|
+
// signed. Private keys are read with op read into memory, never printed, never written to a file nor a command line.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { FORMAT, keyId, publicRecipient, signingKeyFromPrivateKey, verifyRunnerList } from './shared.mjs';
|
|
8
|
+
import { IasError } from './errors.mjs';
|
|
9
|
+
|
|
10
|
+
/** The fields of the 1Password item, as docs/technical/vault.md §2.2 lists them. */
|
|
11
|
+
export const ITEM_FIELDS = Object.freeze({
|
|
12
|
+
recoveryPrivateKey: 'recovery-private-key',
|
|
13
|
+
recoveryPublicKey: 'recovery-public-key',
|
|
14
|
+
recoveryKid: 'recovery-kid',
|
|
15
|
+
signingPrivateKey: 'signing-private-key',
|
|
16
|
+
signingPublicKey: 'signing-public-key',
|
|
17
|
+
signingKid: 'signing-kid',
|
|
18
|
+
format: 'format',
|
|
19
|
+
server: 'server',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const ITEM = /^op:\/\/([^/\s]+)\/([^/]+)$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The 1Password item of the vault of `server`: --item, IAS_VAULT_ITEM, vault.item of ias.json, or op://Private/IAS
|
|
26
|
+
* vault <host>. Returns { reference, vault, title }.
|
|
27
|
+
*/
|
|
28
|
+
export function vaultItem(context, project, server) {
|
|
29
|
+
const reference = context.values?.item ?? context.env.IAS_VAULT_ITEM ?? project?.config?.vault?.item ?? `op://Private/IAS vault ${new URL(server).hostname}`;
|
|
30
|
+
const match = ITEM.exec(reference);
|
|
31
|
+
if (!match) throw new IasError(`«${reference}» is not a 1Password item`, { fix: 'it is op://<vault>/<item>, such as op://Private/IAS vault ias.imagina.build' });
|
|
32
|
+
return { reference, vault: match[1], title: match[2] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const op = (args, { env, input } = {}) => spawnSync('op', args, { encoding: 'utf8', env, input, maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
36
|
+
|
|
37
|
+
/** The first line of what op said, which names the reference and never a value. */
|
|
38
|
+
const opReason = (result) => (result.error ? result.error.message : (result.stderr || '').trim().split('\n').at(-1) || `exit ${result.status}`);
|
|
39
|
+
|
|
40
|
+
/** A field of the item, or undefined if the item has no such field or does not exist. Throws if op cannot answer. */
|
|
41
|
+
export function readField(item, name, env = process.env) {
|
|
42
|
+
const result = op(['read', '--no-newline', `${item.reference}/${name}`], { env });
|
|
43
|
+
if (result.status === 0) return result.stdout;
|
|
44
|
+
const reason = opReason(result);
|
|
45
|
+
if (/isn't an item|isn.t a field|not found|no item|could not find|does not have a field/i.test(reason)) return undefined;
|
|
46
|
+
throw new IasError(`op could not read ${item.reference}/${name}: ${reason}`, { fix: result.error ? 'install the 1Password CLI (brew install 1password-cli) and turn on its integration with the desktop app' : 'unlock 1Password (op signin) and check that the item and its vault exist' });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The public keys of the item, each with its kid checked: { recovery, signing } ({ kid, publicKey }), or undefined. */
|
|
50
|
+
export function publicKeysOf(item, env = process.env) {
|
|
51
|
+
const recovery = readField(item, ITEM_FIELDS.recoveryPublicKey, env);
|
|
52
|
+
if (recovery === undefined) return undefined;
|
|
53
|
+
const signing = readField(item, ITEM_FIELDS.signingPublicKey, env);
|
|
54
|
+
if (signing === undefined) throw new IasError(`${item.reference} has a recovery key but no signing key`, { fix: 'it was made by an older ias: the vault needs both (docs/technical/vault.md §2.2)' });
|
|
55
|
+
try {
|
|
56
|
+
return { recovery: publicRecipient({ kind: 'recovery', kid: keyId(recovery), publicKey: recovery }), signing: { kid: keyId(signing), publicKey: signing } };
|
|
57
|
+
} catch {
|
|
58
|
+
throw new IasError(`the public keys in ${item.reference} are not valid`, { fix: 'restore the item from 1Password\'s history' });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The team's private signing key, from the item, checked against the public one beside it. */
|
|
63
|
+
export function signingKeyOf(item, keys, env = process.env) {
|
|
64
|
+
const seed = readField(item, ITEM_FIELDS.signingPrivateKey, env);
|
|
65
|
+
if (seed === undefined) throw new IasError(`${item.reference} has no ${ITEM_FIELDS.signingPrivateKey}`, { fix: 'signing needs the item ias vault init made, with its private keys: ask an owner to share it with you in 1Password' });
|
|
66
|
+
let pair;
|
|
67
|
+
try {
|
|
68
|
+
pair = signingKeyFromPrivateKey(seed);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new IasError(`${ITEM_FIELDS.signingPrivateKey} of ${item.reference} is not a signing key`, { fix: 'restore the item from 1Password\'s history' });
|
|
71
|
+
}
|
|
72
|
+
if (pair.kid !== keys.signing.kid) throw new IasError(`the signing key in ${item.reference} does not match its public key`, { fix: 'restore the item from 1Password\'s history' });
|
|
73
|
+
return pair;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Creates the item with the keys of `recovery` and `signing` (pairs with their private key). The template goes to op
|
|
78
|
+
* through stdin, so no private key is ever an argument of a process.
|
|
79
|
+
*/
|
|
80
|
+
export function createItem(item, { recovery, signing, server }, env = process.env) {
|
|
81
|
+
const field = (label, value, type = 'STRING') => ({ id: label, label, type, value });
|
|
82
|
+
const template = {
|
|
83
|
+
title: item.title,
|
|
84
|
+
category: 'SECURE_NOTE',
|
|
85
|
+
fields: [
|
|
86
|
+
{ id: 'notesPlain', type: 'STRING', purpose: 'NOTES', label: 'notesPlain', value: `The keys of the IAS vault of ${server} (${FORMAT}): the recovery key opens every sealed credential and the signing key signs them. Made by ias vault init. Never share them outside the team; see docs/technical/vault.md.` },
|
|
87
|
+
field(ITEM_FIELDS.recoveryPrivateKey, recovery.privateKey, 'CONCEALED'),
|
|
88
|
+
field(ITEM_FIELDS.recoveryPublicKey, recovery.publicKey),
|
|
89
|
+
field(ITEM_FIELDS.recoveryKid, recovery.kid),
|
|
90
|
+
field(ITEM_FIELDS.signingPrivateKey, signing.privateKey, 'CONCEALED'),
|
|
91
|
+
field(ITEM_FIELDS.signingPublicKey, signing.publicKey),
|
|
92
|
+
field(ITEM_FIELDS.signingKid, signing.kid),
|
|
93
|
+
field(ITEM_FIELDS.format, FORMAT),
|
|
94
|
+
field(ITEM_FIELDS.server, server),
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
const result = op(['item', 'create', '--vault', item.vault, '--format', 'json', '-'], { env, input: JSON.stringify(template) });
|
|
98
|
+
if (result.status !== 0) throw new IasError(`op could not create ${item.reference}: ${opReason(result)}`, { fix: 'unlock 1Password (op signin) and check that the vault exists and you can write to it' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The runners to seal for: those of the list the team signed (docs/technical/vault.md §2.3), checked with the team's signing
|
|
103
|
+
* public key of 1Password. Runners the server lists outside a valid signed list are left out, with a warning.
|
|
104
|
+
*/
|
|
105
|
+
export function signedRunners(vault, signing, output) {
|
|
106
|
+
const listed = Array.isArray(vault.runners) ? vault.runners : [];
|
|
107
|
+
if (!vault.runner_list) {
|
|
108
|
+
if (listed.length > 0) output?.warn(`the server lists ${listed.length} runner${listed.length === 1 ? '' : 's'} but no list signed by the team: sealing for the recovery key only (ias vault grant signs the list)`);
|
|
109
|
+
return { serial: null, runners: [] };
|
|
110
|
+
}
|
|
111
|
+
let verified;
|
|
112
|
+
try {
|
|
113
|
+
verified = verifyRunnerList(vault.runner_list, signing.publicKey);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new IasError(`the list of runners the server gives is not signed by the team (${error.code})`, { fix: 'nothing was sealed: someone may have changed it on the server; ias vault status says more', code: 'runner_list_invalid' });
|
|
116
|
+
}
|
|
117
|
+
const signed = new Set(verified.runners.map((runner) => runner.kid));
|
|
118
|
+
const outside = listed.filter((runner) => runner?.kid && !signed.has(runner.kid));
|
|
119
|
+
if (outside.length > 0) output?.warn(`${outside.length} runner${outside.length === 1 ? '' : 's'} of the server ${outside.length === 1 ? 'is' : 'are'} not in the list the team signed: left out`);
|
|
120
|
+
return verified;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The kids of the server's keys against the item's: [{ name, server, local, ok }]. */
|
|
124
|
+
export function compareKeys(vault, keys) {
|
|
125
|
+
return ['recovery', 'signing'].map((name) => {
|
|
126
|
+
const remote = vault[`${name}_key`];
|
|
127
|
+
const local = keys?.[name];
|
|
128
|
+
return { name, server: remote ? { kid: remote.kid, publicKey: remote.public_key } : null, local: local ?? null, ok: Boolean(remote && local && remote.public_key === local.publicKey && remote.kid === local.kid) };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What sealing needs, all checked against 1Password: the recipients (the recovery key and the signed runners) and the
|
|
134
|
+
* team's signing key. Stops if the vault has no keys, if the server's keys are not the item's, or if the runner list
|
|
135
|
+
* does not verify.
|
|
136
|
+
*/
|
|
137
|
+
export async function sealingKeys(client, item, env, output) {
|
|
138
|
+
const { data: vault } = await client.request('GET', '/vault');
|
|
139
|
+
if (!vault.recovery_key || !vault.signing_key) throw new IasError('the vault has no keys yet', { fix: 'an owner of the team runs ias vault init', code: 'vault_not_ready' });
|
|
140
|
+
const keys = publicKeysOf(item, env);
|
|
141
|
+
if (!keys) throw new IasError(`there is no ${item.reference} in 1Password`, { fix: 'sealing needs the item of the vault: ask an owner to share it with you, or point --item (IAS_VAULT_ITEM, vault.item of ias.json) at it' });
|
|
142
|
+
const mismatched = compareKeys(vault, keys).filter((entry) => !entry.ok);
|
|
143
|
+
if (mismatched.length > 0) {
|
|
144
|
+
throw new IasError(`the ${mismatched.map((entry) => entry.name).join(' and ')} key${mismatched.length > 1 ? 's' : ''} of the server ${mismatched.length > 1 ? 'are' : 'is'} not the one${mismatched.length > 1 ? 's' : ''} in ${item.reference}`, {
|
|
145
|
+
fix: 'nothing was sealed: ias vault status shows both; if the server changed them, someone may be trying to read or replace the credentials',
|
|
146
|
+
code: 'vault_keys_mismatch',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const signingKey = signingKeyOf(item, keys, env);
|
|
150
|
+
const { runners } = signedRunners(vault, keys.signing, output);
|
|
151
|
+
return { recipients: [keys.recovery, ...runners], signingKey, keys, vault };
|
|
152
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Where ias login keeps the personal token (iasp_…) of each server: the macOS keychain (service ias, account the
|
|
2
|
+
// origin of the server), a file with mode 600 elsewhere, or nowhere on CI, where IAS_TOKEN is the token. The token
|
|
3
|
+
// never goes on a command line, where ps would show it: the keychain gets it through the stdin of security -i.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { IasError } from './errors.mjs';
|
|
10
|
+
|
|
11
|
+
export const KEYCHAIN_SERVICE = 'ias';
|
|
12
|
+
export const STORES = Object.freeze(['keychain', 'file', 'none']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Which store this machine uses: IAS_TOKEN_STORE if it is set; none on CI (CI is set), where tokens come from
|
|
16
|
+
* IAS_TOKEN; the keychain on macOS; a file elsewhere.
|
|
17
|
+
*/
|
|
18
|
+
export function storeKind(env = process.env, platform = process.platform) {
|
|
19
|
+
if (env.IAS_TOKEN_STORE) {
|
|
20
|
+
if (!STORES.includes(env.IAS_TOKEN_STORE)) throw new IasError(`IAS_TOKEN_STORE is ${env.IAS_TOKEN_STORE}`, { fix: `it is one of ${STORES.join(', ')}` });
|
|
21
|
+
return env.IAS_TOKEN_STORE;
|
|
22
|
+
}
|
|
23
|
+
if (env.CI && env.CI !== 'false' && env.CI !== '0') return 'none';
|
|
24
|
+
return platform === 'darwin' ? 'keychain' : 'file';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The file of the tokens when there is no keychain: $XDG_CONFIG_HOME/ias/tokens.json (~/.config by default). */
|
|
28
|
+
export function tokenFile(env = process.env) {
|
|
29
|
+
return join(env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), 'ias', 'tokens.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What people read about where the token is. */
|
|
33
|
+
export const storeLabel = (kind, env = process.env) => ({ keychain: 'the macOS keychain', file: tokenFile(env), none: 'nowhere (CI)' })[kind];
|
|
34
|
+
|
|
35
|
+
function readTokens(file) {
|
|
36
|
+
if (!existsSync(file)) return {};
|
|
37
|
+
const mode = statSync(file).mode & 0o777;
|
|
38
|
+
if (mode & 0o077) throw new IasError(`${file} can be read by other users (mode ${mode.toString(8)})`, { fix: `chmod 600 ${file}, and run ias login again if someone else may have read it` });
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
throw new IasError(`${file} is not valid JSON`, { fix: 'remove it and run ias login again' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeTokens(file, tokens) {
|
|
47
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
+
const temporary = `${file}.${process.pid}`;
|
|
49
|
+
writeFileSync(temporary, `${JSON.stringify(tokens, null, 2)}\n`, { mode: 0o600 });
|
|
50
|
+
chmodSync(temporary, 0o600);
|
|
51
|
+
renameSync(temporary, file);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const security = (args, { env, input } = {}) => spawnSync('security', args, { encoding: 'utf8', env, input, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
55
|
+
|
|
56
|
+
/** The stored token for `server` (an origin), or undefined. */
|
|
57
|
+
export function storedToken(server, { env = process.env, kind = storeKind(env) } = {}) {
|
|
58
|
+
if (kind === 'none') return undefined;
|
|
59
|
+
if (kind === 'file') return readTokens(tokenFile(env))[server]?.token;
|
|
60
|
+
const result = security(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', server, '-w'], { env });
|
|
61
|
+
if (result.error) throw new IasError('the macOS keychain cannot be read (security is missing)', { fix: 'set IAS_TOKEN_STORE=file to keep the token in a file, or IAS_TOKEN' });
|
|
62
|
+
return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Keeps `token` for `server`; returns where it went. */
|
|
66
|
+
export function saveToken(server, token, { env = process.env, kind = storeKind(env) } = {}) {
|
|
67
|
+
if (kind === 'none') throw new IasError('this machine keeps no tokens (CI)', { fix: 'set IAS_TOKEN to a robot token (iasr_…, created in the panel: the application › Settings › Access)' });
|
|
68
|
+
if (!/^iasp_[A-Za-z0-9_-]{43}$/.test(token)) throw new IasError('the server gave a token that is not a personal token (iasp_…)', { fix: 'run ias login again; if it goes on, check the server' });
|
|
69
|
+
if (kind === 'file') {
|
|
70
|
+
const file = tokenFile(env);
|
|
71
|
+
writeTokens(file, { ...readTokens(file), [server]: { token, saved_at: new Date().toISOString() } });
|
|
72
|
+
return file;
|
|
73
|
+
}
|
|
74
|
+
// security -i reads its commands from stdin: the token never is an argument of a process.
|
|
75
|
+
const quote = (value) => `"${value.replace(/["\\]/g, '\\$&')}"`;
|
|
76
|
+
const result = security(['-i'], { env, input: `add-generic-password -U -s ${quote(KEYCHAIN_SERVICE)} -a ${quote(server)} -w ${quote(token)}\n` });
|
|
77
|
+
if (result.error || result.status !== 0 || /error/i.test(result.stderr ?? '')) throw new IasError('the macOS keychain did not take the token', { fix: 'unlock the login keychain and run ias login again, or set IAS_TOKEN_STORE=file' });
|
|
78
|
+
return 'the macOS keychain';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Forgets the token of `server`; true if there was one. */
|
|
82
|
+
export function forgetToken(server, { env = process.env, kind = storeKind(env) } = {}) {
|
|
83
|
+
if (kind === 'none') return false;
|
|
84
|
+
if (kind === 'file') {
|
|
85
|
+
const file = tokenFile(env);
|
|
86
|
+
const tokens = readTokens(file);
|
|
87
|
+
if (!tokens[server]) return false;
|
|
88
|
+
delete tokens[server];
|
|
89
|
+
writeTokens(file, tokens);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return security(['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', server], { env }).status === 0;
|
|
93
|
+
}
|