@imagina-build/ias-cli 0.2.2 → 0.3.1
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 +188 -22
- package/package.json +1 -1
- package/schema/ias.schema.json +46 -1
- package/shared/vault/index.mjs +25 -0
- package/shared/vault/policy-vectors.mjs +237 -0
- package/shared/vault/policy.mjs +427 -0
- package/shared/vault/test-vectors/ias-policy-1/policy.json +4016 -0
- package/shared/vault/vectors.mjs +2 -0
- package/src/apple/capabilities.mjs +96 -0
- package/src/apple/inventory.mjs +75 -0
- package/src/apple/signing.mjs +134 -0
- package/src/applications.mjs +114 -0
- package/src/cli.mjs +28 -10
- package/src/commands/apps/index.mjs +61 -11
- package/src/commands/apps/store.mjs +367 -0
- package/src/commands/auth/index.mjs +3 -3
- package/src/commands/build/index.mjs +2 -0
- package/src/commands/builds/index.mjs +20 -0
- package/src/commands/credentials/apple.mjs +1118 -0
- package/src/commands/credentials/generate.mjs +122 -0
- package/src/commands/credentials/index.mjs +114 -44
- package/src/commands/credentials/seal.mjs +44 -0
- package/src/commands/devices/asc.mjs +50 -8
- package/src/commands/devices/index.mjs +2 -1
- package/src/commands/devices/sync.mjs +57 -4
- package/src/commands/dist/index.mjs +12 -0
- package/src/commands/doctor/index.mjs +3 -0
- package/src/commands/github/index.mjs +13 -3
- package/src/commands/init/index.mjs +6 -3
- package/src/commands/policy/index.mjs +583 -0
- package/src/commands/policy/policy.mjs +182 -0
- package/src/commands/policy/progress.mjs +20 -0
- package/src/commands/releases/index.mjs +225 -0
- package/src/commands/rollout/index.mjs +186 -0
- package/src/commands/runners/index.mjs +19 -0
- package/src/commands/sdk/index.mjs +31 -4
- package/src/commands/setup/index.mjs +184 -0
- package/src/commands/skill/index.mjs +43 -0
- package/src/commands/submit/index.mjs +135 -19
- package/src/commands/testflight/index.mjs +309 -0
- package/src/commands/tokens/index.mjs +99 -0
- package/src/commands/update/index.mjs +26 -5
- package/src/commands/vault/approvers.mjs +5 -1
- package/src/commands/vault/index.mjs +26 -6
- package/src/commands/vault/runners.mjs +48 -2
- package/src/commands/webhooks/index.mjs +19 -5
- package/src/config.mjs +16 -1
- package/src/errors.mjs +52 -6
- package/src/op.mjs +83 -0
- package/src/ota-key.mjs +53 -0
- package/src/provisioning.mjs +281 -0
- package/src/schema.mjs +3 -0
- package/src/setup-state.mjs +76 -0
- package/src/setup.mjs +701 -0
- package/src/skill-files.generated.mjs +10 -0
- package/src/skill.mjs +256 -0
- package/src/team-vault.mjs +2 -2
- package/src/token-store.mjs +1 -1
package/src/skill.mjs
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// The agent skill of IAS: the catalog the server publishes, the copy the CLI carries (skill-files.generated.mjs, written
|
|
2
|
+
// by scripts/sync-skill.mjs from .claude/skills/ias) and the two places a project can get it: an OpenCode catalog in
|
|
3
|
+
// opencode.jsonc and the files themselves in .opencode/skills/ias or .claude/skills/ias. Nothing here uses the network.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { IasError } from './errors.mjs';
|
|
10
|
+
import { SKILL_FILES } from './skill-files.generated.mjs';
|
|
11
|
+
|
|
12
|
+
/** The OpenCode V2 catalog the server serves: index.json, ias/SKILL.md and ias/references/*.md. */
|
|
13
|
+
export const SKILL_CATALOG_URL = 'https://ias.imagina.build/skills/';
|
|
14
|
+
|
|
15
|
+
export const SKILL_NAME = 'ias';
|
|
16
|
+
|
|
17
|
+
/** Where OpenCode looks for a configuration, in the order ias init takes the first one it finds. */
|
|
18
|
+
export const OPENCODE_CONFIG_FILES = ['opencode.jsonc', 'opencode.json', '.opencode/opencode.jsonc', '.opencode/opencode.json'];
|
|
19
|
+
|
|
20
|
+
export const AGENTS_FILE = 'AGENTS.md';
|
|
21
|
+
|
|
22
|
+
/** The section ias init adds to AGENTS.md, once. */
|
|
23
|
+
export const AGENTS_SECTION = [
|
|
24
|
+
'## IAS',
|
|
25
|
+
'',
|
|
26
|
+
'This project uses Imagina Application Services (IAS). Run `ias setup` to see the checklist.',
|
|
27
|
+
`The agent skill is at ${SKILL_CATALOG_URL} (OpenCode) or install it with \`ias skill install\`.`,
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
/** The line a JSONC configuration needs by hand: it is never rewritten. */
|
|
31
|
+
export const SKILL_CONFIG_LINE = `"skills": ["${SKILL_CATALOG_URL}"]`;
|
|
32
|
+
|
|
33
|
+
/** The folder of the skill in this repository, when ias runs from a clone (it is absent in an installed package). */
|
|
34
|
+
export const REPOSITORY_SKILL_DIR = fileURLToPath(new URL('../../../.claude/skills/ias/', import.meta.url));
|
|
35
|
+
|
|
36
|
+
/** The home of `env`, so the tests never touch the real one. */
|
|
37
|
+
export function homeOf(env = process.env) {
|
|
38
|
+
return env?.HOME || env?.USERPROFILE || homedir();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where `ias skill install` writes: [{ harness, path }]. The project's OpenCode folder by default, ~/.config/opencode with
|
|
43
|
+
* --global, and the Claude Code folder as well with --claude (.claude/skills/ias, or ~/.claude/skills/ias with --global).
|
|
44
|
+
*/
|
|
45
|
+
export function skillTargets({ global = false, claude = false, dir = process.cwd(), env = process.env } = {}) {
|
|
46
|
+
const root = global ? homeOf(env) : dir;
|
|
47
|
+
const targets = [{ harness: 'opencode', path: join(root, global ? '.config/opencode/skills' : '.opencode/skills', SKILL_NAME) }];
|
|
48
|
+
if (claude) targets.push({ harness: 'claude', path: join(root, '.claude/skills', SKILL_NAME) });
|
|
49
|
+
return targets;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether `a` and `b` are the same folder (the same path, or the same real path when both exist). */
|
|
53
|
+
function samePath(a, b) {
|
|
54
|
+
const one = resolve(a);
|
|
55
|
+
const other = resolve(b);
|
|
56
|
+
if (one === other) return true;
|
|
57
|
+
try {
|
|
58
|
+
return realpathSync(one) === realpathSync(other);
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sameContents = (file, contents) => existsSync(file) && readFileSync(file, 'utf8') === contents;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Installs the skill from this package (offline) in the target folders. Without `force`, an existing file with other
|
|
68
|
+
* contents stops it before anything is written, and the files are listed. The repository's .claude/skills/ias is the
|
|
69
|
+
* source: when a target is that same folder, it is skipped, never rewritten.
|
|
70
|
+
*
|
|
71
|
+
* Returns { dry_run, targets, files, written, would_write, skipped, source }.
|
|
72
|
+
*/
|
|
73
|
+
export function installSkill({ global = false, claude = false, force = false, dryRun = false, dir = process.cwd(), env = process.env } = {}, output) {
|
|
74
|
+
const targets = skillTargets({ global, claude, dir, env });
|
|
75
|
+
const files = Object.keys(SKILL_FILES);
|
|
76
|
+
const plan = targets.map((target) => {
|
|
77
|
+
const source = samePath(target.path, REPOSITORY_SKILL_DIR);
|
|
78
|
+
const missing = [];
|
|
79
|
+
const identical = [];
|
|
80
|
+
const different = [];
|
|
81
|
+
if (!source) {
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
if (!existsSync(join(target.path, file))) missing.push(file);
|
|
84
|
+
else if (sameContents(join(target.path, file), SKILL_FILES[file])) identical.push(file);
|
|
85
|
+
else different.push(file);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { ...target, source, missing, identical, different };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const conflicts = plan.filter((target) => !target.source && target.different.length > 0);
|
|
92
|
+
if (!force && conflicts.length > 0) {
|
|
93
|
+
const listed = conflicts.flatMap((target) => target.different.map((file) => join(target.path, file)));
|
|
94
|
+
throw new IasError(`the skill is already there with other contents (${listed.join(', ')})`, {
|
|
95
|
+
fix: 'ias skill update replaces it, or ias skill install --force; --dry-run shows the plan without writing',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const written = [];
|
|
100
|
+
const wouldWrite = [];
|
|
101
|
+
const skipped = [];
|
|
102
|
+
for (const target of plan) {
|
|
103
|
+
if (target.source) {
|
|
104
|
+
skipped.push(target.path);
|
|
105
|
+
output?.log(`${output.prefix}: ${target.path} is the source of the skill (.claude/skills/ias): left as it is`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
const path = join(target.path, file);
|
|
110
|
+
const write = target.missing.includes(file) || target.different.includes(file);
|
|
111
|
+
if (!write) {
|
|
112
|
+
skipped.push(path);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (dryRun) {
|
|
116
|
+
wouldWrite.push(path);
|
|
117
|
+
output?.log(`${output.prefix}: would write ${path}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
121
|
+
writeFileSync(path, SKILL_FILES[file]);
|
|
122
|
+
written.push(path);
|
|
123
|
+
output?.log(`${output.prefix}: wrote ${path}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (dryRun) output?.log(`${output.prefix}: dry run: nothing was written (${wouldWrite.length} ${wouldWrite.length === 1 ? 'file' : 'files'})`);
|
|
128
|
+
else if (written.length === 0) output?.log(`${output.prefix}: the skill is already installed (${plan.filter((target) => !target.source).map((target) => target.path).join(', ')})`);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
dry_run: dryRun,
|
|
132
|
+
targets: plan.map((target) => target.path),
|
|
133
|
+
files,
|
|
134
|
+
written,
|
|
135
|
+
would_write: wouldWrite,
|
|
136
|
+
skipped,
|
|
137
|
+
source: plan.filter((target) => target.source).map((target) => target.path),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The first OpenCode configuration of `dir`, in the order OpenCode reads them. */
|
|
142
|
+
export function findProjectConfig(dir) {
|
|
143
|
+
return OPENCODE_CONFIG_FILES.map((name) => join(dir, name)).find((file) => existsSync(file));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether `text` has a // or /* comment outside a string. A URL such as https://ias.imagina.build is not a comment.
|
|
148
|
+
*/
|
|
149
|
+
export function hasJsonComments(text) {
|
|
150
|
+
let inString = false;
|
|
151
|
+
let escaped = false;
|
|
152
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
153
|
+
const character = text[index];
|
|
154
|
+
if (inString) {
|
|
155
|
+
if (escaped) escaped = false;
|
|
156
|
+
else if (character === '\\') escaped = true;
|
|
157
|
+
else if (character === '"') inString = false;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (character === '"') inString = true;
|
|
161
|
+
else if (character === '/' && (text[index + 1] === '/' || text[index + 1] === '*')) return true;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The skill catalog in the project's OpenCode configuration. It creates opencode.jsonc when there is none and merges the
|
|
168
|
+
* URL into skills when the file is plain JSON. A JSONC file (comments) or one that does not parse is never rewritten:
|
|
169
|
+
* the exact line to add is returned instead. Returns { file, action, url, line?, changed }.
|
|
170
|
+
*/
|
|
171
|
+
export function ensureSkillConfig(dir, { dryRun = false } = {}) {
|
|
172
|
+
const file = findProjectConfig(dir);
|
|
173
|
+
if (!file) {
|
|
174
|
+
const target = join(dir, OPENCODE_CONFIG_FILES[0]);
|
|
175
|
+
const config = { $schema: 'https://opencode.ai/config.json', skills: [SKILL_CATALOG_URL] };
|
|
176
|
+
if (!dryRun) writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`);
|
|
177
|
+
return { file: target, action: 'created', url: SKILL_CATALOG_URL, changed: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const text = readFileSync(file, 'utf8');
|
|
181
|
+
let config;
|
|
182
|
+
try {
|
|
183
|
+
config = JSON.parse(text);
|
|
184
|
+
} catch {
|
|
185
|
+
return { file, action: 'manual', url: SKILL_CATALOG_URL, line: SKILL_CONFIG_LINE, changed: false };
|
|
186
|
+
}
|
|
187
|
+
if (hasJsonComments(text) || !config || typeof config !== 'object' || Array.isArray(config)) {
|
|
188
|
+
return { file, action: 'manual', url: SKILL_CATALOG_URL, line: SKILL_CONFIG_LINE, changed: false };
|
|
189
|
+
}
|
|
190
|
+
if (config.skills !== undefined && !Array.isArray(config.skills)) {
|
|
191
|
+
return { file, action: 'manual', url: SKILL_CATALOG_URL, line: SKILL_CONFIG_LINE, changed: false };
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(config.skills) && config.skills.includes(SKILL_CATALOG_URL)) {
|
|
194
|
+
return { file, action: 'unchanged', url: SKILL_CATALOG_URL, changed: false };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
config.skills = [...(config.skills ?? []), SKILL_CATALOG_URL];
|
|
198
|
+
if (!dryRun) writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
199
|
+
return { file, action: 'updated', url: SKILL_CATALOG_URL, changed: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The IAS section of AGENTS.md, appended once. Returns { file, action, changed }. */
|
|
203
|
+
export function ensureAgentsSection(dir, { dryRun = false } = {}) {
|
|
204
|
+
const file = join(dir, AGENTS_FILE);
|
|
205
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
206
|
+
if (/^## IAS\s*$/m.test(text)) return { file, action: 'unchanged', changed: false };
|
|
207
|
+
const base = text.length === 0 ? '' : `${text.endsWith('\n') ? text : `${text}\n`}\n`;
|
|
208
|
+
if (!dryRun) writeFileSync(file, `${base}${AGENTS_SECTION}\n`);
|
|
209
|
+
return { file, action: text.length === 0 ? 'created' : 'appended', changed: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The two halves of the discovery of ias init: the catalog in the OpenCode configuration and the pointer in AGENTS.md.
|
|
214
|
+
* Returns { config, agents, changed } and prints the plan.
|
|
215
|
+
*/
|
|
216
|
+
export function discoverSkill(dir, { dryRun = false } = {}, output) {
|
|
217
|
+
const config = ensureSkillConfig(dir, { dryRun });
|
|
218
|
+
const agents = ensureAgentsSection(dir, { dryRun });
|
|
219
|
+
const verb = dryRun ? 'would' : '';
|
|
220
|
+
if (config.action === 'manual') {
|
|
221
|
+
output?.log(`${output.prefix}: skill: ${config.file} has comments or is not plain JSON, so it is not rewritten; add this line:`);
|
|
222
|
+
output?.log(` ${config.line}`);
|
|
223
|
+
} else if (config.action === 'unchanged') {
|
|
224
|
+
output?.log(`${output.prefix}: skill: ${config.file} already has the catalog ${SKILL_CATALOG_URL}`);
|
|
225
|
+
} else {
|
|
226
|
+
const what = config.action === 'created' ? 'wrote' : 'added the catalog to';
|
|
227
|
+
output?.log(`${output.prefix}: skill: ${dryRun ? `would write` : what} ${config.file}${config.action === 'updated' ? ` (${SKILL_CATALOG_URL})` : ''}`);
|
|
228
|
+
}
|
|
229
|
+
if (agents.action === 'unchanged') {
|
|
230
|
+
output?.log(`${output.prefix}: skill: ${agents.file} already has the IAS section`);
|
|
231
|
+
} else {
|
|
232
|
+
output?.log(`${output.prefix}: skill: ${dryRun ? 'would append' : 'appended'} the IAS section to ${agents.file}`);
|
|
233
|
+
}
|
|
234
|
+
return { config, agents, changed: config.changed || agents.changed };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Whether the project (its configuration or a skill folder) or this computer already has the skill of IAS. */
|
|
238
|
+
export function hasSkill(dir, env = process.env) {
|
|
239
|
+
const config = findProjectConfig(dir);
|
|
240
|
+
if (config && readFileSync(config, 'utf8').includes(SKILL_CATALOG_URL)) return true;
|
|
241
|
+
const home = homeOf(env);
|
|
242
|
+
const folders = [
|
|
243
|
+
join(dir, '.opencode/skills', SKILL_NAME),
|
|
244
|
+
join(dir, '.claude/skills', SKILL_NAME),
|
|
245
|
+
join(home, '.config/opencode/skills', SKILL_NAME),
|
|
246
|
+
join(home, '.opencode/skills', SKILL_NAME),
|
|
247
|
+
join(home, '.claude/skills', SKILL_NAME),
|
|
248
|
+
];
|
|
249
|
+
return folders.some((folder) => existsSync(join(folder, 'SKILL.md')));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The non-blocking hint of ias setup and ias doctor, or undefined when the skill is already there. */
|
|
253
|
+
export function skillHint(dir, env = process.env) {
|
|
254
|
+
if (hasSkill(dir, env)) return undefined;
|
|
255
|
+
return 'the agent skill of IAS is not here: ias init adds its catalog to opencode.jsonc, or ias skill install writes .opencode/skills/ias (--global for ~/.config/opencode/skills/ias, --claude for Claude Code)';
|
|
256
|
+
}
|
package/src/team-vault.mjs
CHANGED
|
@@ -26,9 +26,9 @@ const ITEM = /^op:\/\/([^/\s]+)\/([^/]+)$/;
|
|
|
26
26
|
* vault <host>. Returns { reference, vault, title }.
|
|
27
27
|
*/
|
|
28
28
|
export function vaultItem(context, project, server) {
|
|
29
|
-
const reference = context.values?.item ?? context.env.IAS_VAULT_ITEM ?? project?.config?.vault?.item ?? `op://
|
|
29
|
+
const reference = context.values?.item ?? context.env.IAS_VAULT_ITEM ?? project?.config?.vault?.item ?? `op://IAS/IAS vault ${new URL(server).hostname}`;
|
|
30
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://
|
|
31
|
+
if (!match) throw new IasError(`«${reference}» is not a 1Password item`, { fix: 'it is op://<vault>/<item>, such as op://IAS/IAS vault ias.imagina.build' });
|
|
32
32
|
return { reference, vault: match[1], title: match[2] };
|
|
33
33
|
}
|
|
34
34
|
|
package/src/token-store.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export function storedToken(server, { env = process.env, kind = storeKind(env) }
|
|
|
64
64
|
|
|
65
65
|
/** Keeps `token` for `server`; returns where it went. */
|
|
66
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_
|
|
67
|
+
if (kind === 'none') throw new IasError('this machine keeps no tokens (CI)', { fix: 'set IAS_TOKEN to a robot token (iasr_…): ias tokens create makes one (an owner, kept in 1Password), or the panel: the application › Settings › Access' });
|
|
68
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
69
|
if (kind === 'file') {
|
|
70
70
|
const file = tokenFile(env);
|